Greetings to you, habrovchane, I decided to share with the community quite often (according to personal observations) the mentioned SOLID Go Design post from the blog of Dave Cheney, which I did for my own needs, but someone said that you need to share. Perhaps for someone it will be useful.
This post is based on text from the main GolangUK report of August 18th, 2016.
Recording performances available on YouTube .
How many go programmers in the world? Think of the number and keep it in your head,
we will return to this issue at the end of the conversation.
Who reviews the code here as part of their work? (a large part of the audience raises their hands, which is encouraging). Well, why are you doing code review? (someone shouts "to make the code better")
If code review is needed to catch bad code, then how do you know if the code you are reviewing is good or bad?
Now it's okay to say "this code is terrible" or "wow, this code is beautiful," just as if you said "this painting is beautiful" or "this room is beautiful", but these are subjective concepts, and I am looking for objective ways, to talk about the properties of good or bad code.
What could be bad code properties that you can use when reviewing?
Are these words positive? Would you like to hear these words when reviewing your code?
Perhaps not.
But this is an improvement, now we can say something like “I don’t like it because it’s too hard to modify”, or “I don’t like it because I can't say what this code is trying to do”, but what about to lead the discussion positively?
Wouldn't it be great if there was a way to describe the properties of a good design, not just bad, and to be able to reason in objective terms?
In 2002, Robert Martin published his book Agile Software Development, Principles, Patterns, and Practices . In it, he described the five principles of reusable software design, which he called SOLID principles, an abbreviation of their names.
This book is slightly outdated, the languages ​​in which the conversation is being conducted were used about 10 years ago. But perhaps there are some aspects of the SOLID principle that can give us a clue as to how to talk about well-developed Go programs.
This is exactly what I would like to discuss with you this morning.
The first principle of SOLID, this is S - the principle of common responsibility.
Go does not contain classes at all, instead we have a much more powerful concept of composition, but if you look at the history of using the concept of a class, I think there is a certain meaning here.
Why is it so important that one piece of code has only one reason to change? Well, the idea that your code can change is painful, but it is much less painful than the code on which your code depends, can also change. And when your code needs to change, it should do so in accordance with a specific requirement, and not be a victim of collateral damage.
So code that is responsible for a single task will have fewer reasons for making changes.
Two words that describe how easy it is to make changes to your program are connectedness and unity.
Connectivity is simply a concept that describes a simultaneous change in two code points, when a change in one place means a mandatory change in another.
A related but separate concept, this unity is the force of mutual attraction.
In the context of software, unity is a property that describes parts of code that are naturally interconnected.
To describe the implementation of the principles of connectedness and unity in the Go program, we could talk about functions and methods, as is often the case when discussing SRP (the principle of common responsibility), but I believe that everything starts with the package system in Go.
In Go, all code exists inside packages, and a good package design starts with its name. A package name is both a description of its destination and a namespace prefix. An example of good package names from the standard Go library is:
When you use the characters of another package within your own, this is accomplished using the import
keyword, which establishes a connection at source level between the two packages. Now they know about the existence of each other.
Such a focus on naming is not just pedantry. Badly named packages miss the opportunity to describe their task, even if they had one.
What possibility does the package server
provide? .. perhaps this is the server, but with what protocol does it implement?
What opportunity does package private
offer? Pieces I Shouldn't See? Should he even have any public symbols?
And package common
, exactly like his partner package utils
are often found next to other hard-core violators.
Attracting such packages turns the code into a dump because they have many responsibilities and often change for no reason.
In my view, no discussion of split design would be complete without mentioning Douglas Maclroy's work "Philisophia UNIX"; small, sharp tools that are combined to solve larger problems, often those that were not provided for by the original authors. I think Go packages embody the spirit of UNIX philosophy. In reality, each Go package by itself is a small Go program, the only point of change with sole responsibility.
The second principle O is the principle of openness / closeness of Bertrand Meyer, who in 1988 wrote:
How was this advice applied to languages ​​created 21 years ago?
package main type A struct { year int } func (a A) Greet() { fmt.Println("Hello GolangUK", a.year) } type B struct { A } func (b B) Greet() { fmt.Println("Welcome to GolangUK", b.year) } func main() { var a A a.year = 2016 var b B b.year = 2016 a.Greet() // Hello GolangUK 2016 b.Greet() // Welcome to GolangUK 2016 }
We have type A
, with a year
field and a Greet
method. We have the second type B
in which A
is embedded, the calls to B
methods overlap the calls to A
methods, since A
is embedded, as the field in B
and B
suggests its own Greet
method hiding the similar method in A
But embedding exists not only for methods, it also provides access to the built-in type fields. As you can see, since both A
and B
defined in one package, B
can access the year
's private field in A
, as if it was definitely inside B
So embedding is a powerful tool that allows types in Go to be open for expansion.
package main type Cat struct { Name string } func (c Cat) Legs() int { return 4 } func (c Cat) PrintLegs() { fmt.Printf("I have %d legs\n", c.Legs()) } type OctoCat struct { Cat } func (o OctoCat) Legs() int { return 5 } func main() { var octo OctoCat fmt.Println(octo.Legs()) // 5 octo.PrintLegs() // I have 4 legs }
In this example, we have a Cat
type that can count the number of legs using its Legs
method. We embed this type of Cat
in a new type of OctoCat
and declare that OctocatS
has five legs. In doing so, OctoCat
defines its own Legs
method, which is returned 5, when the PrintLegs
method is PrintLegs
, it returns 4.
This is because PrintLegs
defined inside the Cat
type. It takes Cat
as a receiver and refers to the Cat
Legs
method. Cat
needs to know about the type in which it was embedded, so its method cannot be changed by embedding.
From here we can say that the types in Go are open for expansion and closed for modification .
In fact, the methods in Go are somewhat more than just syntactic sugar around a function with predominantly formal parameters, they are receivers.
func (c Cat) PrintLegs() { fmt.Printf("I have %d legs\n", c.Legs()) } func PrintLegs(c Cat) { fmt.Printf("I have %d legs\n", c.Legs()) }
The receiver is exactly what you pass to it, the first parameter of the function, and since Go does not support function overload, OctoCat
not interchangeable with the usual type of Cats
. Which brings me to the following principle.
Invented by Barbara Liskov, the Liskov substitution principle states that two types are interchangeable if they exhibit a behavior in which the caller cannot determine the difference.
In a class-based language, the Liskov substitution principle is often interpreted as a specification for an abstract class with various specific subtypes. But there are no classes or inheritance in Go, so the substitution cannot be implemented in terms of the hierarchy of the abstract class.
Instead, substitution is the Go interface's competency. In Go, types are not required to implement a specific interface; instead, any type of implementation interface simply contains a method whose signature corresponds to the interface declaration.
We say that in Go, interfaces are implicitly satisfied instead of explicit matching, and this has a profound effect on how they are used in the language.
A well-designed interface, this is most likely a small interface; The prevailing idiom is that the interface contains only a single method. It is logical that the small interface contains a simple implementation, since it is difficult to do otherwise. From which it follows that packages are a compromise solution of simple implementations connected by ordinary behavior .
type Reader interface { // Read reads up to len(buf) bytes into buf. Read(buf []byte) (n int, err error) }
Which brings me to io.Reader
my favorite interface in Go.
The io.Reader
interface io.Reader
very simple; Read
reads the data into the specified buffer and returns to the calling code the number of bytes that were read, and any error that may occur during the reading process. It looks simple, but it is very powerful.
Because io.Reader
deals with anything that can be expressed as a stream of bytes, we can construct objects from literally anything; a constant string, byte array, standard input stream, network stream, gzip tar archive, standard output stream, or a command executed remotely via ssh.
And all these implementations are interchangeable, since they satisfy one simple contract.
So, the Liskov substitution principle is applicable in Go and what has been said can be summed up with the beautiful aphorism of the late Jim Weirich:
And this is a great transition to the fourth principle of SOLID.
The fourth principle is the interface separation principle, which reads:
In Go, the application of the principle of interface separation can be understood as the process of isolating the behavior of a necessary function to perform its work. As a concrete example, let's say I have a task to write a function that saves the Document
structure to disk.
// Save writes the contents of doc to the file f. func Save(f *os.File, doc *Document) error
I can define such a function. let's call it Save
, it accepts *os.File
as the source for recording the provided Document
. But here there are several problems.
The Save
signature prevents the ability to write data to any address on the network. Suppose that the network storage is likely to become a requirement in the future and the signature of this function may change, which will affect everyone who calls it.
Since Save
operates directly on disk files, testing it is rather unpleasant. To verify operations, tests must read the contents of the file after recording. In addition, tests must make sure that f
was recorded in temporary storage and is always deleted afterwards.
*os.File
also defines many methods that are not relevant to Save
, like reading directories and checking whether the path is a symbolic link. It would be useful if the signature of our Save
function was described only by those parts of *os.File
that are relevant to its task.
What can we do with these problems?
// Save writes the contents of doc to the supplied ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error
Using io.ReadWriteCloser
we can apply the interface separation principle to override Save
so that it accepts an interface that describes the more common file operations tasks.
With these changes, any type that implements the io.ReadWriteCloser
interface can be replaced with the previous *os.File
. This makes the application of Save
wider and explains to the caller of Save
, which method of the *os.File
type is relevant to the desired operation.
As the author of Save
I should no longer be able to call all irrelevant methods from the *os.File
, since they are hidden behind the io.ReadWriteCloser
interface. But we can go a little further with the interface sharing method.
First, it is unlikely that Save
follows the principle of sole responsibility, it will read the file that just recorded to check the contents, which should be the responsibility of another part of the code. Therefore, we can narrow the interface specification, which we pass to Save
only before opening and closing the file.
// Save writes the contents of doc to the supplied WriteCloser. func Save(wc io.WriteCloser, doc *Document) error
Secondly, by providing Save
with the stream closing mechanism that we inherited with the desire to make it look like the usual file handling mechanism, the question arises under what circumstances wc
will be closed. Perhaps Save
will call Close
without any conditions, or Close
will be called if successful.
All of this represents a problem for the caller Save
, since it may wish to add additional information to the stream after the document is already written.
type NopCloser struct { io.Writer } // Close has no effect on the underlying writer. func (c *NopCloser) Close() error { return nil }
A rough solution would be to define a new type that embeds io.Writer
and overrides the Close
method, preventing Save
from being called from a closed main thread.
But this is most likely a violation of Barbara Liskov’s principle of substitution, since NopCloser
doesn’t really close anything.
// Save writes the contents of doc to the supplied Writer. func Save(w io.Writer, doc *Document) error
Much better would be the decision to override Save
, accepting only io.Writer
, completely preventing it from doing anything other than writing data to the stream.
But applying the principle of interface separation to our Save
function, the result simultaneously becomes a function that is most specific in terms of its requirements, the only thing it needs is something to write to and the most important thing in this function is that we can use Save
for saving our data to any place where the io.Writer
interface is io.Writer
.
The above quote is an interesting meme that has leaked into the spirit of Go over the past few years.
In this version, in the framework of the standard tweet, one nuance is missing and this is not Jack’s fault, but I think that it represents one of the main reasons for the appearance of dictionaries about Go language design.
The final principle of SOLID is the dependency inversion principle, which states:
But what does inversion in practice mean for a Go programmer?
If you apply all the principles we talked about up to this point, then your code should already be placed in discrete packages, each with a single and well-defined dependency or goal. Your code should describe its dependencies in terms of interfaces and these interfaces should be aimed at describing exclusively the behavior that is required by these functions. In other words, there should be a lot of work left.
So, as I imagine what Martin is talking about here, mainly in the context of Go, this is the structure of your import graph.
In Go, your import graph should be acyclic. Attempting to ignore acyclicity will result in a compilation error, but a much more serious error may be in the architecture. Other things being equal, the import graph of a well-designed Go program should be wide and relatively flat instead of being high and narrow. If you have a package whose functions cannot be performed without the help of another package, this may be a signal that the boundaries of the package are not well defined.
The principle of dependency inversion encourages you to transfer the specific responsibility as high as possible in the import column to your main
package or to the upper level of the processor, leaving the lower level of the code to work with abstractions and interfaces.
As a summary, when each of the SOLID principles applies to Go, they are a powerful design tool, but used together, they are the main theme.
The principle of common responsibility encourages you to structure the functions, types and methods into packages that are naturally interconnected; Types and functions together serve a single purpose.
The principle of openness / closeness encourages you to compromise simple types and more complex ones by using paste.
Barbara Liskov's principle of substitution encourages you to express the dependencies between your packages in terms of interfaces, rather than specific types. By defining small interfaces, we can be more confident that the implementations will satisfy their contracts.
The principle of interface separation continues this idea and encourages you to define functions and methods that depend only on the behavior that they need. If your functions need only an interface type parameter with a single method, then most likely these functions have sole responsibility.
The principle of inversion of dependencies encourages you to move knowledge about the dependencies of your package from the compilation stage; in Go we see this with a reduction in the number of imports used by a particular package to the code execution stage.
If you want to summarize this conversation, then most likely it will be: the interfaces allow you to apply the SOLID principles in Go programs .
Because interfaces allow Go programmers to describe the capabilities of their packages, rather than a specific implementation. All of this is just another way of saying "disconnecting", which is the goal, since loosely coupled code is easier to change.
As Sandi Metz noted:
Because if Go plans to be the language in which companies invest in the long term, the key factor in their decision will be how easy it is to maintain the code on Go and how easy it is to change.
In conclusion, let's return to the question with which I opened this conversation. How many go programmers around the world? Here is my guess:
What will half a million Go programmers do with their time? Well, obviously, they will write a lot of code on Go, and if we are honest, not all the code will be good, some of the code will be bad.
Please understand that I am not trying to be cruel, but each of you in this room with experience in other languages, the languages ​​from which you came to Go knows from your own experience that there is some truth in this prediction.
The ability to help our language succeed for every programmer is not to create such a mess that people start talking about when they joke about C ++ today.
Stories that make fun of other languages ​​for being bloated, wordy, and overloaded once can be applied to Go and I don’t want this to happen and therefore I have a request.
Go programmers need to stop talking about frameworks and start talking more about design. We must stop focusing on performance at all costs and focus instead on reuse at all costs.
I would like to see today how people talk about how to use the language that we have, regardless of their choice and limitations, to create solutions and solve real problems.
I would like to hear today how people talk about program design on Go in such a way that they are well designed, disconnected, re-usable and responsive to change.
Now it’s great that so many people today came to listen to such an excellent composition of speakers, but the reality is that no matter how much this conference grows, compared to the total number of people who use Go throughout his life, we are only small part.
Therefore, our task is to tell the rest of the world how good software should be written. Good software, compatible, changeable and show them how to do it using Go. And it starts with you.
I want you to start talking about design, maybe use some of the ideas that I presented here, I hope you will conduct your own research and apply these ideas to your project. Then I want you to:
-Wrote a blog post about it.
-Tell at the workshop what you have done.
-Write a book about what you have learned.
And come back to this conference next year and tell us about what you have achieved.
By doing all this we will be able to form an ecosystem of Go developers who take care of their programs designed to keep working.
Thank.
Source: https://habr.com/ru/post/348852/
All Articles