📜 ⬆️ ⬇️

The SOLID principle in the Go language

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.


SOLID go design


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?


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.


Code Review


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.


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.


Good design


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?


SOLID


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.


Principle of sole responsibility


The first principle of SOLID, this is S - the principle of common responsibility.


The class must have one and only one reason for the change.
-Robert S. Martin

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.


Connectedness & Unity


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.


Package Names


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.


Bad package names


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.


Philisophia UNIX in Go


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.


Principle of openness / closeness


The second principle O is the principle of openness / closeness of Bertrand Meyer, who in 1988 wrote:


Software objects must be open for expansion and closed for modification.
- Bertrand Meyer, Building Object-Oriented Software

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.


Barbara Liskov substitution 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.


Interfaces


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 .


io.Reader


 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:


Require no more, promise no less.
- Jim Weirich

And this is a great transition to the fourth principle of SOLID.


Interface separation principle


The fourth principle is the interface separation principle, which reads:


Clients should not be forced to depend on methods that they do not use.
-Robert S. Martin

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 .


An important rule of thumb for Go, is to accept interfaces, and return structures .
-Jack Lindamud

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.


Dependency Inversion Principle


The final principle of SOLID is the dependency inversion principle, which states:


Top level modules should not be dependent on lower level modules. Both levels should depend on abstractions.
Abstractions should not depend on their details. Details must depend on abstractions.
-Robert S. Martin

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.


SOLID go design


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:


Design is the art of organizing code that should work today and is always easy to change.
-Sandi Metz

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.


Conclusion


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:


In 2020 there will be 500,000 developers on Go.
-Dave Cheney

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.


In C ++, there is a much cleaner and evolutionary language that is trying to exit.
Byorn Straustrup, Design and Evolution C ++

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.


... and one more detail


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.




Original post by Dave Cheney


')

Source: https://habr.com/ru/post/348852/


All Articles