Source: www.cyningsun.com/08-03-2019/…
Code review
How many of you do code review as part of your daily routine? [The whole room raises its hand, inspiring]. Ok, why do you need to conduct code review? [Cries of “Stop bad code”]
If code reviews are designed to catch bad code, how do you know if the code you’re reviewing is good or bad?
Just as you might say “this painting is beautiful” or “this room is beautiful,” now you can say “the code is ugly” or “the source code is beautiful,” but these are all subjective. I’m looking for features to talk about code in an objective way, whether it’s good or bad.
Bad code
You may encounter the following characteristics of bad code in Code Review:
- Rigid– Is the code rigid? Does it have strong types or arguments that make it difficult to modify?
- Fragile– Is the code fragile? Can subtle changes cause immeasurable damage in your code base?
- Immobile– Is the code hard to refactor? Code that can avoid looping imports with just a tap on the keyboard?
- Complex– Is there code to show off, is it overdesigned?
- Verbose– Is the code hard to use? Can you see what the code is doing when you read it?
Are these words positive? Would you like to see these words used to review your code?
Surely not.
Good design
But it’s a step forward, now we can say “I don’t like it because it’s too hard to fix,” or “I don’t like it because I don’t know what the code is trying to do,” but how about forward guidance?
Wouldn’t it be great if there were some way to describe bad design, as well as the characteristics of good design, and to do so in an objective way?
SOLID
In 2002, Robert Martin published his book Agile Software Development, Principles, Patterns, and Practices, which describes five Principles of reusable Software design, Called SOLID (acronym) principles:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
The book is a bit dated; the language it discusses is one used more than a decade ago. However, perhaps some aspect of SOLID principles can give us a clue as to how to talk about a well-designed Go program.
Single Responsibility Principle
The first principle of SOLID, S, is the single liability principle.
A class should have one, and only one, reason to change. — Robert C Martin
Now Go obviously doesn’t have classses – instead, we have the much stronger concept of combinations – but if you could review the use of the word class, I think it would be of some value at this point.
Why does it matter why a piece of code has only one change? Well, as frustrating as it is that your own code might change, it’s even more painful to find the code your code depends on changing under your feet. When your code has to change, it should respond to a direct stimulus to make the change and not be a victim of collateral damage.
Therefore, there are the fewest reasons for code changes with a single responsibility.
Coupling & Cohesion
Two words that describe how easy or difficult it is to change software are coupling and cohesion.
- Coupling is just a word that describes two things that change together — one movement induces another.
- A related but independent concept is cohesion, the force of mutual attraction.
In the context of software, cohesion is the property that describes the natural attraction between snippets of code.
To describe the coupled and cohesive units in Go programs, we’ll probably talk about functions and methods, which are common when discussing SRP, but I believe it starts with Go’s package model.
SRP: Single Responsibility Principle
Package names
In Go, all the code is in some package, and a well-designed package starts with its name. The package name is both a description of its purpose and a namespace prefix. Some excellent package examples from the Go library:
net/http
– Provides HTTP clients and serversos/exec
– Execute external commandsencoding/json
– Implement JSON document encoding and decoding
When you use another Pakcage’s symbols internally, use the import declaration, which creates a source-level coupling between the two packages. They now know each other’s existence.
Bad package names
This focus on names is not pedantic. A poorly named package loses the opportunity to list its purpose if it actually has one.
server
What does package offer? . Well, hopefully the server, but what protocol does it use?private
What does package offer? Something I’m not supposed to see? Should it have a public symbol?common
Package, and its companionutils
Package is often found along with other ‘partners’
We see all these packages like this, they become all kinds of garbage dumps, because they have many responsibilities, so they often change for no reason.
Go ‘s UNIX philosophy
In my opinion, no discussion of decoupling design would be complete without mentioning Doug McIlroy’s Unix philosophy; Small, sharp tools combine to solve larger tasks, often tasks unimaginable to the original author.
I think the Go Package embodies the spirit of the Unix philosophy. In effect, each Go package is itself a small Go program, a single unit of change, with a single responsibility.
Open/Closed Principle
The second principle, O, is Bertrand Meyer’s Open/Closed principle, who wrote in 1988:
Software entities should be open for extension, but closed for modification. — Bertrand Meyer, Object-Oriented Software Construction
How does this advice apply to languages written 21 years later?
package main
type A struct {
year int
}
func (a A) Greet(a) { fmt.Println("Hello GolangUK", a.year) }
type B struct {
A
}
func (b B) Greet(a) { fmt.Println("Welcome to GolangUK", b.year) }
func main(a) {
var a A
a.year = 2016
var b B
b.year = 2016
a.Greet() // Hello GolangUK 2016
b.Greet() // Welcome to GolangUK 2016
}
Copy the code
We have A type A with A field year and A method Greet. We have the second type, B which embeds an A, because A embeds, so the caller sees that B’s methods override A’s methods. Because A is embedded in B as A field, B can provide its own Greet method, which covers A’s Greet method.
But embedding is not just for methods; fields of the embedded type can also be accessed. As you can see, because both A and B are defined in the same package, B can access A’s private Year field just as if it were declared in B.
So embedding is a powerful tool that allows the types of Go to be open to extensions.
package main
type Cat struct {
Name string
}
func (c Cat) Legs(a) int { return 4 }
func (c Cat) PrintLegs(a) {
fmt.Printf("I have %d legs\n", c.Legs())
}
type OctoCat struct {
Cat
}
func (o OctoCat) Legs(a) int { return 5 }
func main(a) {
var octo OctoCat
fmt.Println(octo.Legs()) / / 5
octo.PrintLegs() // I have 4 legs
}
Copy the code
In this example, we have a Cat type that counts its Legs using its Legs method. We embedded the Cat type into a new type, OctoCat, and declared Octocats to have five legs. However, OctoCat defines its own Legs method, which returns 5, but when the PrintLegs method is called, it returns 4.
This is because PrintLegs are defined on type Cat. It needs Cat as its receiver, so it sends it to Cat’s Legs method. Cat does not know what type it is embedded, so it cannot change its method set when it is embedded.
Thus, we can say that the types of Go, while open to extension, are closed to modification.
In fact, the methods in Go are nothing more than syntactic sugar around functions with pre-declared formal parameters, known as sinks.
func (c Cat) PrintLegs(a) {
fmt.Printf("I have %d legs\n", c.Legs())
}
func PrintLegs(c Cat) {
fmt.Printf("I have %d legs\n", c.Legs())
}
Copy the code
The receiver is the first argument to the function you pass in, OctoCat, and because Go does not support function overloading, OctoCat cannot replace regular Cat. Which brings me to the next principle.
Liskov Substitution Principle
The Richter substitution principle, proposed by Barbara Liskov, roughly states that two types are fungible if they exhibit behavior that makes the caller indistinguishable.
In class-based languages, the Richter substitution principle is usually interpreted as a specification for abstract base classes with various concrete subtypes. But Go has no classes or inheritance, so there is no way to implement substitution based on an abstract class hierarchy.
Interfaces
Instead, substitution is the scope of the Go interface. In Go, types do not need to specify that they implement a particular interface, but rather that any type implements the interface as long as it has methods whose signatures match the interface declaration.
We say that in Go, interfaces are implicitly satisfied rather than explicitly, which has a profound effect on how they are used in the language.
Well-designed interfaces are more likely to be small ones; The popular approach is for an interface to contain only one method. Logically, small interfaces make implementation easy, and vice versa. The result is a package that consists of simple implementations of common behavior.
io.Reader
type Reader interface {
// Read reads up to len(buf) bytes into buf.
Read(buf []byte) (n int, err error)
}
Copy the code
This brings me to my favorite Go interface, IO.Reader.
The IO.Reader interface is very simple; Read reads data into the provided buffer and returns the number of bytes Read and any errors encountered during the Read to the caller. It looks simple, but it’s very powerful.
Since IO.Reader can handle anything represented as a byte stream, we can create a Reader on almost anything; Constant strings, byte arrays, standard input, network streams, gzip tar files, standard output of commands executed remotely over SSH.
And all of these implementations are interchangeable because they implement the same simple contract.
Thus, the Richter substitution principle that applies to Go can be summed up in the aphorism of the late Jim Weirich.
Require no more, promise no less. — Jim Weirich
Turn to the fourth principle of “SOLID” smoothly.
Interface Segregation Principle
The fourth principle is the interface isolation principle, which is as follows:
Clients should not be forced to depend on methods they do not use. — Robert C. Martin
In Go, the application of the interface isolation principle can refer to the process of isolating the behavior required by a function to do its job. To take a concrete example, suppose I have completed the task of writing a function to save the Document structure to disk.
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error
Copy the code
I can define this function, let’s call it Save, which writes the given Document to * os.file. But there are problems with that.
The signature for Save excludes the option of writing data to a network location. Assuming that network storage may later become a requirement, the signature of this feature must change, affecting all of its callers.
Because Save operates directly on files on disk, it is inconvenient to test. To verify its operation, the test must read the contents of the file after writing. In addition, the test must ensure that f is written to the temporary position and then removed.
* os.file also defines a number of methods unrelated to Save, such as reading a directory and checking if the path is a File link. It would be useful if the signature of the Save function described only the relevant parts of * os.file.
How do we deal with these problems?
// Save writes the contents of doc to the supplied ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error
Copy the code
With IO.ReadWriteCloser we can apply the interface isolation principle and redefine Save with an interface of more general file types.
With this change, any type that implements the io.readWritecLoser interface can replace the previous * os.file. Make the Save application broader and clarify to Save callers which methods of the * os.file type are relevant to the operation.
As the writer of Save, I no longer have the option to call the unrelated methods of * os.file, which is hidden behind the io.readWritecLoser interface. We can take the interface isolation principle a step further.
First, if Save followed the single responsibility principle, it would be impossible for it to read the file it just wrote to verify its contents – that should be the responsibility of another piece of code. Therefore, we can narrow the specification of the interface we pass to Save to write and close only.
// Save writes the contents of doc to the supplied WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
Copy the code
Second, by providing Save with a mechanism to close its stream, we continue this mechanism to make it look like something of a file type, which raises the question of under what circumstances WC will close. Save may call Close unconditionally, or it may call Close on success.
This causes problems for the caller of Save, because it might want to write other data to the stream after writing to the document.
type NopCloser struct {
io.Writer
}
// Close has no effect on the underlying writer.
func (c *NopCloser) Close(a) error { return nil }
Copy the code
A rough solution is to define a new type that emitters an IO.Writer and overwrites the Close method to prevent the Save method from closing the underlying data stream.
But that would probably violate the Richter substitution principle, since NopCloser doesn’t actually close anything.
// Save writes the contents of doc to the supplied Writer.
func Save(w io.Writer, doc *Document) error
Copy the code
A better solution would be to redefine Save to accept only IO.Writer, completely stripping it of responsibility for doing anything except writing data to a stream.
By applying the interface isolation principle, our Save function, while at the same time getting one of the most specific functions in terms of requirements – it only needs one writable argument – has the most general functionality, we can now use Save to Save our data to any place that implements IO.Writer.
A great rule of thumb for Go is accept interfaces, return structs. — Jack Lindamood
This quote is an interesting meme, to say the least, that has permeated the Go zeitgeist over the past few years.
It’s not Jack’s fault that this tiny version of Twitter lacks detail, but I think it represents the first legitimate Go design tradition
Dependency Inversion Principle
The final SOLID principle is the reliance on inversion principle, which states:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not Details should depend on Abstractions. – Robert C. Martin
But what does dependency inversion mean in practice for Go programmers?
If you’ve applied all of the principles we talked about earlier, your code should have been broken down into discrete packages, each with a clearly defined responsibility or purpose. Your code should describe its dependencies in terms of interfaces, and should consider those interfaces to describe only the behavior required by those functions. In other words, nothing else should be done.
So I think, in the context of Go, Martin is referring to the structure of the Import Graph.
In Go, the import graph must be acyclic. Failure to comply with this acyclic requirement causes compilation to fail, but more seriously it represents a serious error in the design.
All things being equal, the import graph of a well-designed Go program should be wide and relatively flat, not tall and narrow. If you have a package whose functions cannot run without the aid of another package, then this may be an indication that the code is not properly decomposed along the Pakcage boundary.
The dependency inversion principle encourages you to push specific responsibilities as far up the import Graph as possible, to the main package or top-level handler, leaving lower-level code to handle the abstract interface.
SOLID Go Design
To review, when applied to Go, each SOLID principle is a powerful statement about design, but taken together they have a central theme.
- The single responsibility principle encourages you to structure functions, types, and methods into packages that are naturally cohesive; Types belong to each other, and functions serve a single purpose.
- The open/Close principle encourages you to use embedding to group simple types into more complex types.
- The Richter substitution principle encourages you to express dependencies between packages in terms of interfaces rather than specific types. By defining small interfaces, we can be more confident that implementations will faithfully fulfill their contracts.
- The interface isolation principle takes this idea one step further and encourages you to define functions and methods that rely only on their desired behavior. If your function only needs methods with arguments of a single interface type, it is more likely that the function will have only one responsibility.
- The dependency inversion principle encourages you to transfer the knowledge that a package depends on in sequence from compile time to run time. In Go, we can see this in the reduction in the number of import statements used by a particular package.
If you had to sum up this presentation, it might be this: Interfaces let you apply the SOLID principles to Go programs.
Because interfaces let Go programmers describe what their package provides – not how it does it. In other words, “decoupling,” which is really the goal, because the more loosely coupled the software, the easier it is to modify.
As Sandi Metz says:
Design is the art of arranging code that needs to work today, and to be easy to change forever. — Sandi Metz
Because if Go is going to be a long-term investment language for the company, the maintainability of the Go program, and the ease of change, will be a key factor in their decision.
At the end
Finally, let’s go back to the question I opened this talk with; How many Go programmers are there in the world? Here’s my guess:
By 2020, there will be 500,000 Go developers. -me
What are half a million Go programmers doing with their time? Well, obviously, they’re going to write a lot of Go code, and to be honest, not all of it is going to be good, and some of it is going to be terrible.
Please understand that I am not being cruel in saying this, but everyone in this room who has had experience with the development of other languages — the language you come from, coming to Go — knows from your own experience that there is some truth to this prophecy.
Within C++, there is a lot of smaller and cleaner language struggling to get out. — Bjarne Stroustrup, The Design and Evolution of C++
All programmers have a chance to make our language a success, relying on our collective ability not to mess things up when people start talking about Go like they do with C++ jokes today.
Narratives that mock other languages are too long, too long and too complex, and one day will turn to GO, and I don’t want to see that happen, so I have a request.
Go programmers need to talk less about frameworks and more about design. We need to stop focusing on performance at all costs and focus wholeheartedly on reuse.
What I’d like to see is people talking about how to use the language we use today, regardless of its choices and limitations, to design solutions and solve practical problems.
What I want to hear is people talking about how to design Go programs in a way that is well designed, decoupled, reused, and most importantly, responsive to change.
… one more thing
It’s great that everyone here today is hearing from so many speakers, but the truth is, no matter how big this conference is, we’re only a fraction of the number of people who have used Go in its lifetime.
So we need to tell the rest of the world how to write good software. Good software, software that can be combined, software that is easy to change, and show them how to change using Go. Start with you.
I want you to start talking about design, maybe using some of the ideas I’ve put forward here, and hopefully you’ll do your own research and apply those ideas to your projects. Then I want you to:
- Write a blog post about design.
- Teach a workshop on design.
- Write a book about what you learned.
- Come back to this meeting next year and talk about your accomplishments.
Because by doing these things, we can build a culture of Go developers who care about designing programs for persistence.
thank you
The original:SOLID Go Design