• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

Practical Go: Real world advice for writing maintainable Go programs

原作者: [db:作者] 来自: [db:来源] 收藏 邀请
 

1. Guiding principles

If I’m going to talk about best practices in any programming language I need some way to define what I mean by best. If you came to my keynote yesterday you would have seen this quote from the Go team lead, Russ Cox:

Software engineering is what happens to programming when you add time and other programmers.
— Russ Cox

Russ is making the distinction between software programming and software engineering. The former is a program you write for yourself, the latter is a product that many people will work on over time. Engineers will come and go, teams will grow and shrink, requirements will change, features will be added and bugs fixed. This is the nature of software engineering.

I’m possibly one of the earliest users of Go in this room, but to argue that my seniority gives my views more weight is false. Instead, the advice I’m going to present today is informed by what I believe to be the guiding principles underlying Go itself. They are:

  1. Simplicity

  2. Readability

  3. Productivity

NOTE

You’ll note that I didn’t say performance, or concurrency. There are languages which are a bit faster than Go, but they’re certainly not as simple as Go. There are languages which make concurrency their highest goal, but they are not as readable, nor as productive.

Performance and concurrency are important attributes, but not as important as simplicityreadability, and productivity.

1.1. Simplicity

Simplicity is prerequisite for reliability.
— Edsger W. Dijkstra

Why should we strive for simplicity? Why is important that Go programs be simple?

We’ve all been in a situation where you say "I can’t understand this code", yes? We’ve all worked on programs where you’re scared to make a change because you’re worried it’ll break another part of the program; a part you don’t understand and don’t know how to fix. This is complexity.

There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies. The first method is far more difficult.
— C. A. R. Hoare

Complexity turns reliable software in unreliable software. Complexity is what kills software projects. Therefore simplicity is the highest goal of Go. Whatever programs we write, we should be able to agree that they are simple.

1.2. Readability

Readability is essential for maintainability.
— Mark ReinholdJVM language summit 2018

Why is it important that Go code be readable? Why should we strive for readability?

Programs must be written for people to read, and only incidentally for machines to execute.
— Hal Abelson and Gerald SussmanStructure and Interpretation of Computer Programs

Readability is important because all software, not just Go programs, is written by humans to be read by other humans. The fact that software is also consumed by machines is secondary.

Code is read many more times than it is written. A single piece of code will, over its lifetime, be read hundreds, maybe thousands of times.

The most important skill for a programmer is the ability to effectively communicate ideas.
— Gastón Jorquera [1]

Readability is key to being able to understand what the program is doing. If you can’t understand what a program is doing, how can you hope to maintain it? If software cannot be maintained, then it will be rewritten; and that could be the last time your company will invest in Go.

If you’re writing a program for yourself, maybe it only has to run once, or you’re the only person who’ll ever see it, then do what ever works for you. But if this is a piece of software that more than one person will contribute to, or that will be used by people over a long enough time that requirements, features, or the environment it runs in changes, then your goal must be for your program to be maintainable.

The first step towards writing maintainable code is making sure the code is readable.

1.3. Productivity

Design is the art of arranging code to work today, and be changeable forever.
— Sandi Metz

The last underlying principle I want to highlight is productivity. Developer productivity is a sprawling topic but it boils down to this; how much time do you spend doing useful work verses waiting for your tools or hopelessly lost in a foreign code-base. Go programmers should feel that they can get a lot done with Go.

The joke goes that Go was designed while waiting for a C++ program to compile. Fast compilation is a key feature of Go and a key recruiting tool to attract new developers. While compilation speed remains a constant battleground, it is fair to say that compilations which take minutes in other languages, take seconds in Go. This helps Go developers feel as productive as their counterparts working in dynamic languages without the reliability issues inherent in those languages.

More fundamental to the question of developer productivity, Go programmers realise that code is written to be read and so place the act of reading code above the act of writing it. Go goes so far as to enforce, via tooling and custom, that all code be formatted in a specific style. This removes the friction of learning a project specific dialect and helps spot mistakes because they just look incorrect.

Go programmers don’t spend days debugging inscrutable compile errors. They don’t waste days with complicated build scripts or deploying code to production. And most importantly they don’t spend their time trying to understand what their coworker wrote.

Productivity is what the Go team mean when they say the language must scale.

2. Identifiers

The first topic we’re going to discuss is identifiers. An identifier is a fancy word for a name; the name of a variable, the name of a function, the name of a method, the name of a type, the name of a package, and so on.

Poor naming is symptomatic of poor design.

Given the limited syntax of Go, the names we choose for things in our programs have an oversized impact on the readability of our programs. Readability is the defining quality of good code, thus choosing good names is crucial to the readability of Go code.

2.1. Choose identifiers for clarity, not brevity

Obvious code is important. What you can do in one line you should do in three.

Go is not a language that optimises for clever one liners. Go is not a language which optimises for the least number of lines in a program. We’re not optimising for the size of the source code on disk, nor how long it takes to type the program into an editor.

Good naming is like a good joke. If you have to explain it, it’s not funny.

Key to this clarity is the names we choose for identifies in Go programs. Let’s talk about the qualities of a good name:

  • A good name is concise. A good name need not be the shortest it can possibly be, but a good name should waste no space on things which are extraneous. Good names have a high signal to noise ratio.

  • A good name is descriptive. A good name should describe the application of a variable or constant, not their contents. A good name should describe the result of a function, or behaviour of a method, not their implementation. A good name should describe the purpose of a package, not its contents. The more accurately a name describes the thing it identifies, the better the name.

  • A good name is should be predictable. You should be able to infer the way a symbol will be used from its name alone. This is a function of choosing descriptive names, but it also about following tradition. This is what Go programmers talk about when they say idiomatic.

Let’s talk about each of these properties in depth.

2.2. Identifier length

Sometimes people criticise the Go style for recommending short variable names. As Rob Pike said, "Go programmers want the right length identifiers". [1]

Andrew Gerrand suggests that by using longer identifies to indicate to the reader things of higher importance.

The greater the distance between a name’s declaration and its uses, the longer the name should be.
— Andrew Gerrand [2]

From this we can draw some guidelines:

  • Short variable names work well when the distance between their declaration and last use is short.

  • Long variable names need to justify themselves; the longer they are the more value they need to provide. Lengthy bureaucratic names carry a low amount of signal compared to their weight on the page.

  • Don’t include the name of your type in the name of your variable.

  • Constants should describe the value they hold, not how that value is used.

  • Prefer single letter variables for loops and branches, single words for parameters and return values, multiple words for functions and package level declarations

  • Prefer single words for methods, interfaces, and packages.

  • Remember that the name of a package is part of the name the caller uses to to refer to it, so make use of that.

Let’s look at an example to

type Person struct {
	Name string
	Age  int
}

// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
	if len(people) == 0 {
		return 0
	}

	var count, sum int
	for _, p := range people {
		sum += p.Age
		count += 1
	}

	return sum / count
}

In this example, the range variable p is declared on line 10 and only referenced once, on the following line. p lives for a very short time both on the page, and during the execution of the function. A reader who is interested in the effect values of p have on the program need only read two lines.

By comparison people is declared in the function parameters and lives for seven lines. The same is true for sum, and count, thus they justify their longer names. The reader has to scan a wider number of lines to locate them so they are given more distinctive names.

I could have chosen s for sum and c (or possibly n) for count but this would have reduced all the variables in the program to the same level of importance. I could have chosen p instead of people but that would have left the problem of what to call the for …​ range iteration variable. The singular person would look odd as the loop iteration variable which lives for little time has a longer name than the slice of values it was derived from.

TIP
Use blank lines to break up the flow of a function in the same way you use paragraphs to break up the flow of a document. In AverageAge we have three operations occurring in sequence. The first is the precondition, checking that we don’t divide by zero if people is empty, the second is the accumulation of the sum and count, and the final is the computation of the average.

2.2.1. Context is key

It’s important to recognise that most advice on naming is contextual. I like to say it is a principle, not a rule.

What is the difference between two identifiers, i, and index. We cannot say conclusively that one is better than another, for example is

for index := 0; index < len(s); index++ {
	//
}

fundamentally more readable than

for i := 0; i < len(s); i++ {
	//
}

I argue it is not, because it is likely the scope of i, and index for that matter, is limited to the body of the for loop and the extra verbosity of the latter adds little to comprehension of the program.

However, which of these functions is more readable?

func (s *SNMP) Fetch(oid []int, index int) (int, error)

or

func (s *SNMP) Fetch(o []int, i int) (int, error)

In this example, oid is an abbreviation for SNMP Object ID, so shortening it to o would mean programmers have to translate from the common notation that they read in documentation to the shorter notation in your code. Similarly, reducing index to i obscures what i stands for as in SNMP messages a sub value of each OID is called an Index.

TIP
Don’t mix and match long and short formal parameters in the same declaration.

2.3. Don’t name your variables for their types

You shouldn’t name your variables after their types for the same reason you don’t name your pets "dog" and "cat". You also probably shouldn’t include the name of your type in the name of your variable’s name for the same reason.

The name of the variable should describe its contents, not the type of the contents. Consider this example:

var usersMap map[string]*User

What’s good about this declaration? We can see that its a map, and it has something to do with the *User type, that’s probably good. But usersMap is a map, and Go being a statically typed language won’t let us accidentally use it where a scalar variable is required, so the Map suffix is redundant.

Now, consider what happens if we were to declare other variables like:

var (
	companiesMap map[string]*Company
	productsMap  map[string]*Products
)

Now we have three map type variables in scope, usersMapcompaniesMap, and productsMap, all mapping strings to different types. We know they are maps, and we also know that their map declarations prevent us from using one in place of another—​the compiler will throw an error if we try to use companiesMap where the code is expecting a map[string]*User. In this situation it’s clear that the Map suffix does not improve the clarity of the code, its just extra boilerplate to type.

My suggestion is to avoid any suffix that resembles the type of the variable.

TIP
If users isn’t descriptive enough, then usersMap won’t be either.

This advice also applies to function parameters. For example:

type Config struct {
	//
}

func WriteConfig(w io.Writer, config *Config)

Naming the *Config parameter config is redundant. We know its a *Config, it says so right there.

In this case consider conf or maybe c will do if the lifetime of the variable is short enough.

If there is more that one *Config in scope at any one time then calling them conf1 and conf2 is less descriptive than calling them original and updated as the latter are less likely to be mistaken for one another.

NOTE
Don’t let package names steal good variable names.

The name of an imported identifier includes its package name. For example the Context type in the context package will be known as context.Context. This makes it impossible to use context as a variable or type in your package.

func WriteLog(context context.Context, message string)

Will not compile. This is why the local declaration for context.Context types is traditionally ctx. eg.

func WriteLog(ctx context.Context, message string)

2.4. Use a consistent naming style

Another property of a good name is it should be predictable. The reader should be able to understand the use of a name when they encounter it for the first time. When they encounter a common name, they should be able to assume it has not changed meanings since the last time they saw it.

For example, if your code passes around a database handle, make sure each time the parameter appears, it has the same name. Rather than a combination of d *sql.DBdbase *sql.DBDB *sql.DB, and database *sql.DB, instead consolidate on something like;

db *sql.DB

Doing so promotes familiarity; if you see a db, you know it’s a *sql.DB and that it has either been declared locally or provided for you by the caller.

Similar advice applies to method receivers; use the same receiver name every method on that type. This makes it easier for the reader to internalise the use of the receiver across the methods in this type.

NOTE
The convention for short receiver names in Go is at odds with the advice provided so far. This is just one of the choices made early on that has become the preferred style, just like the use of CamelCase rather than snake_case.
TIP

Go style dictates that receivers have a single letter name, or acronyms derived from their type. You may find that the name of your receiver sometimes conflicts with name of a parameter in a method. In this case, consider making the parameter name slightly longer, and don’t forget to use this new parameter name consistently.

Finally, certain single letter variables have traditionally been associated with loops and counting. For example, ij, and k are commonly the loop induction variable for simple for loops. n is commonly associated with a counter or accumulator. v is a common shorthand for a value in a generic encoding function, k is commonly used for the key of a map, and s is often used as shorthand for parameters of type string.

As with the db example above programmers expect i to be a loop induction variable. If you ensure that i is always a loop variable, not used in other contexts outside a for loop. When readers encounter a variable called i, or j, they know that a loop is close by.

TIP
If you found yourself with so many nested loops that you exhaust your supply of ij, and k variables, its probably time to break your function into smaller units.

2.5. Use a consistent declaration style

Go has at least six different ways to declare a variable

  • var x int = 1

  • var x = 1

  • var x int; x = 1

  • var x = int(1)

  • x := 1

I’m sure there are more that I haven’t thought of. This is something that Go’s designers recognise was probably a mistake, but its too late to change it now. With all these different ways of declaring a variable, how do we avoid each Go programmer choosing their own style?

I want to present a suggestions for how I declare variables in my programs. This is the style I try to use where possible.

  • When declaring, but not initialising, a variable, use var. When declaring a variable that will be explicitly initialised later in the function, use the var keyword.

    var players int    // 0
    
    var things []Thing // an empty slice of Things
    
    var thing Thing    // empty Thing struct
    json.Unmarshall(reader, &thing)

    The var acts as a clue to say that this variable has been deliberately declared as the zero value of the indicated type. This is also consistent with the requirement to declare variables at the package level using var as opposed to the short declaration syntax—​although I’ll argue later that you shouldn’t be using package level variables at all.

  • When declaring and initialising, use :=. When declaring and initialising the variable at the same time, that is to say we’re not letting the variable be implicitly initialised to its zero value, I recommend using the short variable declaration form. This makes it clear to the reader that the variable on the left hand side of the := is being deliberately initialised.

    To explain why, Let’s look at the previous example, but this time deliberately initialising each variable:

var players int = 0

var things []Thing = nil

var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)

In the first and third examples, because in Go there are no automatic conversions from one type to another; the type on the left hand side of the assignment operator must be identical to the type on the right hand side. The compiler can infer the type of the variable being declared from the type on the right hand side, to the example can be written more concisely like this:

var players = 0

var things []Thing = nil

var thing = new(Thing)
json.Unmarshall(reader, thing)

This leaves us with explicitly initialising players to 0 which is redundant because 0 is `players’ zero value. So its better to make it clear that we’re going to use the zero value by instead writing

var players int

What about the second statement? We cannot elide the type and write

var things = nil

Because nil does not have a type. [2] Instead we have a choice, do we want the zero value for a slice?

var things []Thing

or do we want to create a slice with zero elements?

var things = make([]Thing, 0)

If we wanted the latter then this is not the zero value for a slice so we should make it clear to the reader that we’re making this choice by using the short declaration form:

things := make([]Thing, 0)

Which tells the reader that we have chosen to initialise things explicitly.

This brings us to the third declaration,

var thing = new(Thing)

Which is both explicitly initialising a variable and introduces the uncommon use of the new keyword which some Go programmer dislike. If we apply our short declaration syntax recommendation then the statement becomes

thing := new(Thing)

Which makes it clear that thing is explicitly initialised to the result of new(Thing)--a pointer to a Thing--but still leaves us with the unusual use of new. We could address this by using the compact literal struct initialiser form,

thing := &Thing{}

Which does the same as new(Thing), hence why some Go programmers are upset by the duplication. However this means we’re explicitly initialising thing with a pointer to a Thing{}, which is the zero value for a Thing.

Instead we should recognise that thing is being declared as its zero value and use the address of operator to pass the address of thing to json.Unmarshall

var thing Thing
json.Unmarshall(reader, &thing)
NOTE

Of course, with any rule of thumb, there are exceptions. For example, sometimes two variables are closely related so writing

var min int
max := 1000

Would be odd. The declaration may be more readable like this

min, max := 0, 1000

In summary:

  • When declaring a variable without initialisation, use the var syntax.

  • When declaring and explicitly initialising a variable, use :=.

TIP
Make tricky declarations obvious.

When something is complicated, it should look complicated.

var length uint32 = 0x80

Here length may be being used with a library which requires a specific numeric type and is more explicit that length is being explicitly chosen to be uint32 than the short declaration form:

length := uint32(0x80)

In the first example I’m deliberately breaking my rule of using the var declaration form with an explicit initialiser. This decision to vary from my usual form is a clue to the reader that something unusual is happening.

2.6. Be a team player

I talked about a goal of software engineering to produce readable, maintainable, code. Therefore you will likely spend most of your career working on projects of which you are not the sole author. My advice in this situation is to follow the local style.

Changing styles in the middle of a file is jarring. Uniformity, even if its not your preferred approach, is more valuable for maintenance than your personal preference. My rule of thumb is; if it fits through gofmt then its usually not worth holding up a code review for.

TIP
If you want to do a renaming across a code-base, do not mix this into another change. If someone is using git bisect they don’t want to wade through thousands of lines of renaming to find the code you changed as well.

3. Comments

Before we move on to larger items I want to spend a few minutes talking about comments.

Good code has lots of comments, bad code requires lots of comments.
— Dave Thomas and Andrew HuntThe Pragmatic Programmer

Comments are very important to the readability of a Go program. Each comments should do one—​and only one—​of three things:

  1. The comment should explain what the thing does.

  2. The comment should explain how the thing does what it does.

  3. The comment should explain why the thing is why it is.

The first form is ideal for commentary on public symbols:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.

The second form is ideal for commentary inside a method:

// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
        results = append(results, execute(seen, dep))
}

The third form, the why , is unique as it does not displace the first two, but at the same time it’s not a replacement for the what, or the how. The why style of commentary exists to explain the external factors that drove the code you read on the page. Frequently those factors rarely make sense taken out of context, the comment exists to provide that context.

return &v2.Cluster_CommonLbConfig{
	// Disable HealthyPanicThreshold
    HealthyPanicThreshold: &envoy_type.Percent{
    	Value: 0,
    },
}

In this example it may not be immediately clear what the effect of setting HealthyPanicThreshold to zero percent will do. The comment is needed to clarify that the value of 0 will disable the panic threshold behaviour.

3.1. Comments on variables and constants should describe their contents not their purpose

I stated earlier that the name of a variable, or a constant, should describe its purpose. When you add a comment to a variable or constant, that comment should describe the variables contents, not the variables purpose.

const randomNumber = 6 // determined from an unbiased die

In this example the comment describes why randomNumber is assigned the value six, and where the six was derived from. The comment does not describe where randomNumber will be used. Here are some more examples:

const (
    StatusContinue           = 100 // RFC 7231, 6.2.1
    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
    StatusProcessing         = 102 // RFC 2518, 10.1

    StatusOK                 = 200 // RFC 7231, 6.3.1

In the context of HTTP the number 100 is known as StatusContinue, as defined in RFC 7231, section 6.2.1.

TIP

For variables without an initial value, the comment should describe who is responsible for initialising this variable.

// sizeCalculationDisabled indicates whether it is safe
// to calculate Types' widths and alignments. See dowidth.
var sizeCalculationDisabled bool

Here the comment lets the reader know that the dowidth function is responsible for maintaining the state of sizeCalculationDisabled.

TIP
Hiding in plain sight

This is a tip from Kate Gregory. [3] Sometimes you’ll find a better name for a variable hiding in a comment.

// registry of SQL drivers
var registry = make(map[string]*sql.Driver)

The comment was added by the author because registry doesn’t explain enough about its purpose—​it’s a registry, but a registry of what?

By renaming the variable to sqlDrivers its now clear that the purpose of this variable is to hold SQL drivers.

var sqlDrivers = make(map[string]*sql.Driver)

Now the comment is redundant and can be removed.

3.2. Always document public symbols

Because godoc is the documentation for your package, you should always add a comment for every public symbol—​variable, constant, function, and method—​declared in your package.

Here are two rules from the Google Style guide

  • Any public function that is not both obvious and short must be commented.

  • Any function in a library must be commented regardless of length or complexity

package ioutil

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)

There is one exception to this rule; you don’t need to document methods that implement an interface. Specifically don’t do this:

// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)

This comment says nothing. It doesn’t tell you what the method does, in fact it’s worse, it tells you to go look somewhere else for the documentation. In this situation I suggest removing the comment entirely.

Here is an example from the io package

// LimitReader returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying implementation is a *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
	R Reader // underlying reader
	N int64  // max bytes remaining
}

func (l *LimitedReader) Read(p []byte) (n int, err error) {
	if l.N <= 0 {
		return 0, EOF
	}
	if int64(len(p)) > l.N {
		p = p[0:l.N]
	}
	n, err = l.R.Read(p)
	l.N -= int64(n)
	return
}

Note that the LimitedReader declaration is directly preceded by the function that uses it, and the declaration of LimitedReader.Read follows the declaration of LimitedReader itself. Even though LimitedReader.Read has no documentation itself, its clear from that it is an implementation of io.Reader.

TIP
Before you write the function, write the comment describing the function. If you find it hard to write the comment, then it’s a sign that the code you’re about to write is going to be hard to understand.

3.2.1. Don’t comment bad code, rewrite it

Don’t comment bad code — rewrite it
— Brian Kernighan

Comments highlighting the grossness of a particular piece of code are not sufficient. If you encounter one of these comments, you should raise an issue as a reminder to refactor it later. It is okay to live with technical debt, as long as the amount of debt is known.

The tradition in the standard library is to annotate a TODO style comment with the username of the person who noticed it.

// TODO(dfc) this is O(N^2), find a faster way to do this.

The username is not a promise that that person has committed to fixing the issue, but they may be the best person to ask when the time comes to address it. Other projects annotate TODOs with a date or an issue number.

3.2.2. Rather than commenting a block of code, refactor it

Good code is its own best documentation. As you’re about to add a comment, ask yourself, 'How can I improve the code so that this comment isn’t needed?' Improve the code and then document it to make it even clearer.
— Steve McConnell

Functions should do one thing only. If you find yourself commenting a piece of code because it is unrelated to the rest of the function, consider extracting it into a function of its own.

In addition to being easier to comprehend, smaller functions are easier to test in isolation. Once you’ve isolated the orthogonal code into its own function, its name may be all the documentation required.

4. Package Design

Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations.

Each Go package is in effect it’s own small Go program. Just as the implementation of a function or method is unimportant to the caller, the implementation of the functions, methods and types that comprise your package’s public API—​its behaviour—​is unimportant for the caller.

A good Go package should strive to have a low degree of source level coupling such that, as the project grows, changes to one package do not cascade across the code-base. These stop-the-world refactorings place a hard limit on the rate of change in a code base and thus the productivity of the members working in that code-base.

In this section we’ll talk about designing a package—​including the package’s name—​naming types, and tips for writing methods and functions.

4.1. A good package starts with its name

Writing a good Go package starts with the package’s name. Think of your package’s name as an elevator pitch to describe what it does using just one word.

Just as I talked about names for variables in the previous section, the name of a package is very important. The rule of thumb I follow is not, "what types should I put in this package?". Instead the question I ask "what does service does package provide?" Normally the answer to that question is not "this package provides the X type", but "this package let’s you speak HTTP".

TIP
Name your package for what it provides, not what it contains.

4.1.1. Good package names should be unique.

Within your project, each package name should be unique. This should pretty easy to if you’ve followed the advice that a package’s name should derive from its purpose. If you find you have two packages which need the same name, it is likely either;

  1. The name of the package is too generic.

  2. The package overlaps another package of a similar name. In this case either you should review your design, or consider merging the packages.

4.2. Avoid package names like basecommon, or util

A common cause of poor package names is what call utility packages. These are packages where common helpers and utility code congeals over time. As these packages contain an assortment of unrelated functions, their utility is hard to describe in terms of what the package provides. This often leads to the package’s name being derived from what the package contains--utilities.

Package names like utils or helpers are commonly found in larger projects which have developed deep package hierarchies and want to share helper functions without encountering import loops. By extracting utility functions to new package the import loop is broken, but because the package stems from a design problem in the project, its name doesn’t reflect its purpose, only its function of breaking the import cycle.

My recommendation to improve the name of utils or helpers packages is to analyse where they are called and if possible move the relevant functions into their caller’s package. Even if this involves duplicating some helper code this is better than introducing an import dependency between two packages.

[A little] duplication is far cheaper than the wrong abstraction.
— Sandy Metz

In the case where utility functions are used in many places prefer multiple packages, each focused on a single aspect, to a single monolithic package.

TIP
Use plurals for naming utility packages. For example the strings for string handling utilities.

Packages with names like base or common are often found when functionality common to two or more implementations, or common types for a client and server, has been refactored into a separate package. I believe the solution to this is to reduce the number of packages, to combine the client, server, and common code into a single package named after the function of the package.

For example, the net/http package does not have client and server sub packages, instead it has a client.go and server.go file, each holding their respective types, and a transport.go file for the common message transport code.

TIP
An identifier’s name includes its package name.

It’s important to remember that the name of an identifier includes the name of its package.

  • The Get function from the net/http package becomes http.Get when referenced by another package.

  • The Reader type from the strings package becomes strings.Reader when imported into other packages.

  • The Error interface from the net package is clearly related to network errors.

4.3. Return early rather than nesting deeply

As Go does not use exceptions for control flow there is no requirement to deeply indent your code just to provide a top level structure for the try and catch blocks. Rather than the successful path nesting deeper and deeper to the right, Go code is written in a style where the success path continues down the screen as the function progresses. My friend Mat Ryer calls this practice 'line of sight' coding. [4]

This is achieved by using guard clauses; conditional blocks with assert preconditions upon entering a function. Here is an example from the bytes package,

func (b *Buffer) UnreadRune() error {
	if b.lastRead <= opInvalid {
		return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
	}
	if b.off >= int(b.lastRead) {
		b.off -= int(b.lastRead)
	}
	b.lastRead = opInvalid
	return nil
}

Upon entering UnreadRune the state of b.lastRead is checked and if the previous operation was not ReadRune an error is returned immediately. From there the rest of the function proceeds with the assertion that b.lastRead is greater that opInvalid.

Compare this to the same function written without a guard clause,

func (b *Buffer) UnreadRune() error {
	if b.lastRead > opInvalid {
		if b.off >= int(b.lastRead) {
			b.off -= int(b.lastRead)
		}
		b.lastRead = opInvalid
		return nil
	}
	return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}

The body of the successful case, the most common, is nested inside the first if condition and the successful exit condition, return nil, has to be discovered by careful matching of closing braces. The final line of the function now returns an error, and the called must trace the execution of the function back to the matching opening brace to know when control will reach this point.

This is more error prone for the reader, and the maintenance programmer, hence why Go prefer to use guard clauses and returning early on errors.

4.4. Make the zero value useful

Every variable declaration, assuming no explicit initialiser is provided, will be automatically initialised to a value that matches the contents of zeroed memory. This is the values zero value. The type of the value determines the value’s zero value; for numeric types it is zero, for pointer types nil, the same for slices, maps, and channels.

This property of always setting a value to a known default is important for safety and correctness of your program and can make your Go programs simpler and more compact. This is what Go programmers talk about when they say "give your structs a useful zero value".

Consider the sync.Mutex type. sync.Mutex contains two unexported integer fields, representing the mutex’s internal state. Thanks to the zero value those fields will be set to will be set to 0 whenever a sync.Mutex is declared. sync.Mutex has been deliberately coded to take advantage of this property, making the type usable without explicit initialisation.

type MyInt struct {
	mu  sync.Mutex
	val int
}

func main() {
	var i MyInt

	// i.mu is usable without explicit initialisation.
	i.mu.Lock()
	i.val++
	i.mu.Unlock()
}

Another example of a type with a useful zero value is bytes.Buffer. You can declare a bytes.Buffer and start writing to it without explicit initialisation.

func main() {
	var b bytes.Buffer
	b.WriteString("Hello, world!\n")
	io.Copy(os.Stdout, &b)
}

A useful property of slices is their zero value is nil. This makes sense if we look at the runtime’s definition of a slice header.

type slice struct {
        array *[...]T // pointer to the underlying array
        len   int
        cap   int
}

The zero value of this struct would imply len and cap have the value 0, and array, the pointer to memory holding the contents of the slice’s backing array, would be nil. This means you don’t need to explicitly make a slice, you can just declare it.

func main() {
	// s := make([]string, 0)
	// s := []string{}
	var s []string

	s = append(s, "Hello")
	s = append(s, "world")
	fmt.Println(strings.Join(s, " "))
}
NOTE

var s []string is similar to the two commented lines above it, but not identical. It is possible to detect the difference between a slice value that is nil and a slice value that has zero length. The following code will output false.

func main() {
	var s1 = []string{}
	var s2 []string
	fmt.Println(reflect.DeepEqual(s1, s2))
}

A useful, albeit surprising, property of uninitialised pointer variables—​nil pointers—​is you can call methods on types that have a nil value. This can be used to provide default values simply.

type Config struct {
	path string
}

func (c *Config) Path() string {
	if c == nil {
		return "/usr/home"
	}
	return c.path
}

func main() {
	var c1 *Config
	var c2 = &Config{
		path: "/export",
	}
	fmt.Println(c1.Path(), c2.Path())
}

4.5. Avoid package level state

The key to writing maintainable programs is that they should be loosely coupled—​a change to one package should have a low probability of affecting another package that does not directly depend on the first.

There are two excellent ways to achieve loose coupling in Go

  1. Use interfaces to describe the behaviour your functions or methods require.

  2. Avoid the use of global state.

In Go we can declare variables at the function or method scope, and also at the package scope. When the variable is public, given a identifier starting with a capital letter, then its scope is effectively global to the entire program—​any package may observe the type and contents of that variable at any time.

Mutable global state introduces tight coupling between independent parts of your program as global variables become an invisible parameter to every function in your program! Any function that relies on a global variable can be broken if that variable’s type changes. Any function that relies on the state of a global variable can be broken if another part of the program changes that variable.

If you want to reduce the coupling a global variable creates,

  1. Move the relevant variables as fields on structs that need them.

  2. Use interfaces to reduce the coupling between the behaviour and the implementation of that behaviour.

5. Project Structure

Let’s talk about combining packages together into a project. Commonly this will be a single git repository. In the future Go developers will use the terms module and project interchangeably.

Just like a package, each project should have a clear purpose. If your project is a library, it should provide one thing, say XML parsing, or logging. You should avoid combining multiple purposes into a single project, this will help avoid the dreaded common library.

TIP
In my experience, the common repo ends up tightly coupled to its biggest consumer and that makes it hard to back-port fixes without upgrading both common and consumer in lock step, bringing in a lot of unrelated changes and API breakage along the way.

If your project is an application, like your web application, Kubernetes controller, and so on, then you might have one or more main packages inside your project. For example, the Kubernetes controller I work on has a single cmd/contour package which serves as both the server deployed to a Kubernetes cluster, and a client for debugging purposes.

5.1. Consider fewer, larger packages

One of the things I tend to pick up in code review for programmers who are transitioning from other languages to Go is they tend to overuse packages.

Go does not provide elaborate ways of establishing visibility. Go lacks Java’s publicprotectedprivate, and implicit default access modifiers. There is no equivalent of C++'s notion of a friend classes.

In Go we have only two access modifiers, public and private, indicated by the capitalisation of the first letter of the identifier. If an identifier is public, it’s name starts with a capital letter, that identifier can be referenced by any other Go package.

NOTE
You may hear people say exported and not exported as synonyms for public and private.

Given the limited controls available to control access to a package’s symbols, what practices should Go programmers follow to avoid creating over-complicated package hierarchies?

TIP
Every package, with the exception of cmd/ and internal/, should contain some source code.

The advice I find myself repeating is to prefer fewer, larger packages. Your default position should be to not create a new package. That will lead to too many types being made public creating a wide, shallow, API surface for your package..

The sections below explores this suggestion in more detail.

TIP
Coming from Java?

If you’re coming from a Java or C# background, consider this rule of thumb. - A Java package is equivalent to a single .go source file. - A Go package is equivalent to a whole Maven module or .NET assembly.

5.1.1. Arrange code into files by import statements

If you’re arranging your packages by what they provide to callers, should you do the same for files within a Go package? How do you know when you should break up a .go file into multiple ones? How do you know when you’ve gone to far and should consi


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
为什么Go语言不支持重载?发布时间:2022-07-10
下一篇:
Go学习笔记03-附录发布时间:2022-07-10
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap