En solid guide till SOLID-principer

1. Introduktion

I denna handledning kommer vi att diskutera SOLID-principerna för objektorienterad design.

Först börjar vi med att utforska skälen till att de kom till och varför vi bör överväga dem när vi utformar programvara. Sedan beskriver vi varje princip tillsammans med någon exempelkod för att betona poängen.

2. Anledningen till SOLID-principer

SOLID-principerna konceptualiserades först av Robert C. Martin i sin uppsats från 2000, Design Principles and Design Patterns. Dessa koncept byggdes senare av Michael Feathers, som introducerade oss till SOLID-förkortningen. Och under de senaste 20 åren har dessa 5 principer revolutionerat en värld av objektorienterad programmering och förändrat sättet vi skriver programvara på.

Så, vad är SOLID och hur hjälper det oss att skriva bättre kod? Enkelt uttryckt uppmuntrar Martins och Feathers designprinciper oss att skapa mer underhållbar, förståelig och flexibel programvara . Följaktligen, eftersom våra applikationer växer i storlek, kan vi minska deras komplexitet och spara oss mycket huvudvärk längre ner på vägen!

Följande 5 koncept utgör våra SOLID-principer:

  1. S ingle Ansvaret
  2. O penna / stängd
  3. L iskov-ersättning
  4. Jag interagerar med segregering
  5. D ependency Inversion

Medan vissa av dessa ord kan låta skrämmande kan de lätt förstås med några enkla kodexempel. I följande avsnitt tar vi en djupdykning i vad var och en av dessa principer betyder, tillsammans med ett snabbt Java-exempel för att illustrera var och en.

3. Ensamt ansvar

Låt oss starta saker med principen om ensamansvar. Som vi kan förvänta oss säger denna princip att en klass bara ska ha ett ansvar. Dessutom borde det bara ha en anledning att ändra.

Hur hjälper denna princip oss att bygga bättre programvara? Låt oss se några av dess fördelar:

  1. Testning - En klass med ett ansvar kommer att ha mycket färre testfall
  2. Lägre koppling - Mindre funktionalitet i en enda klass har färre beroenden
  3. Organisation - Mindre, välorganiserade lektioner är lättare att söka än monolitiska

Ta till exempel en klass som representerar en enkel bok:

public class Book { private String name; private String author; private String text; //constructor, getters and setters }

I den här koden lagrar vi namn, författare och text associerad med en förekomst av en bok .

Låt oss nu lägga till ett par metoder för att fråga texten:

public class Book { private String name; private String author; private String text; //constructor, getters and setters // methods that directly relate to the book properties public String replaceWordInText(String word){ return text.replaceAll(word, text); } public boolean isWordInText(String word){ return text.contains(word); } }

Nu fungerar vår bokklass bra, och vi kan lagra så många böcker som vi vill i vår applikation. Men vad nyttar det med att lagra informationen om vi inte kan mata ut texten till vår konsol och läsa den?

Låt oss vara försiktiga mot vinden och lägga till en utskriftsmetod:

public class Book { //... void printTextToConsole(){ // our code for formatting and printing the text } }

Den här koden bryter dock mot principen om ett enda ansvar som vi skisserade tidigare. För att fixa vår röra bör vi implementera en separat klass som bara handlar om att skriva ut våra texter:

public class BookPrinter { // methods for outputting text void printTextToConsole(String text){ //our code for formatting and printing the text } void printTextToAnotherMedium(String text){ // code for writing to any other location.. } }

Grymt bra. Vi har inte bara utvecklat en klass som befriar boken från dess tryckuppgifter, men vi kan också använda vår BookPrinter- klass för att skicka vår text till andra medier.

Oavsett om det är e-post, loggning eller något annat, har vi en separat klass dedikerad till det här problemet.

4. Öppet för förlängning, stängt för modifiering

Nu är det dags för 'O' - mer formellt känd som den öppna-stängda principen . Enkelt uttryckt bör klasserna vara öppna för förlängning, men stängda för modifiering. Genom att göra det hindrar vi oss från att ändra befintlig kod och orsaka potentiella nya buggar i en annars lycklig applikation.

Naturligtvis är det enda undantaget från regeln när du fixar buggar i befintlig kod.

Låt oss utforska konceptet vidare med ett snabbt exempel. Föreställ dig att vi har implementerat en gitarrklass som en del av ett nytt projekt .

Den är fullfjädrad och har till och med en volymknapp:

public class Guitar { private String make; private String model; private int volume; //Constructors, getters & setters }

Vi startar applikationen och alla älskar den. Men efter några månader bestämmer vi oss för att gitarren är lite tråkig och skulle kunna göra med ett fantastiskt flammönster för att få den att se lite mer "rock and roll" ut.

Vid det här laget kan det vara frestande att bara öppna gitarrklassen och lägga till ett flammönster - men vem vet vilka fel som kan kasta upp i vår applikation.

Istället, låt oss hålla oss till den öppna slutna principen och enkelt utöka vår gitarr klass :

public class SuperCoolGuitarWithFlames extends Guitar { private String flameColor; //constructor, getters + setters }

Genom att utöka gitarrklassen kan vi vara säkra på att vår befintliga applikation inte påverkas.

5. Liskov-byte

Nästa upp på vår lista är Liskov-substitution, som förmodligen är den mest komplexa av de fem principerna. Enkelt uttryckt, om klass A är en undertyp av klass B , borde vi kunna ersätta B med A utan att störa beteendet hos vårt program.

Låt oss bara hoppa direkt till koden för att linda huvudet runt detta koncept:

public interface Car { void turnOnEngine(); void accelerate(); }

Ovan definierar vi en enkel bil gränssnitt med ett par metoder som alla bilar ska kunna uppfylla - slå på motorn och accelererar framåt.

Låt oss implementera vårt gränssnitt och ge lite kod för metoderna:

public class MotorCar implements Car { private Engine engine; //Constructors, getters + setters public void turnOnEngine() { //turn on the engine! engine.on(); } public void accelerate() { //move forward! engine.powerOn(1000); } }

Som vår kod beskriver har vi en motor som vi kan slå på och vi kan öka effekten. Men vänta, det är 2019, och Elon Musk har varit en upptagen man.

We are now living in the era of electric cars:

public class ElectricCar implements Car { public void turnOnEngine() { throw new AssertionError("I don't have an engine!"); } public void accelerate() { //this acceleration is crazy! } }

By throwing a car without an engine into the mix, we are inherently changing the behavior of our program. This is a blatant violation of Liskov substitution and is a bit harder to fix than our previous 2 principles.

One possible solution would be to rework our model into interfaces that take into account the engine-less state of our Car.

6. Interface Segregation

The ‘I ‘ in SOLID stands for interface segregation, and it simply means that larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.

For this example, we're going to try our hands as zookeepers. And more specifically, we'll be working in the bear enclosure.

Let's start with an interface that outlines our roles as a bear keeper:

public interface BearKeeper { void washTheBear(); void feedTheBear(); void petTheBear(); }

As avid zookeepers, we're more than happy to wash and feed our beloved bears. However, we're all too aware of the dangers of petting them. Unfortunately, our interface is rather large, and we have no choice than to implement the code to pet the bear.

Let's fix this by splitting our large interface into 3 separate ones:

public interface BearCleaner { void washTheBear(); } public interface BearFeeder { void feedTheBear(); } public interface BearPetter { void petTheBear(); }

Now, thanks to interface segregation, we're free to implement only the methods that matter to us:

public class BearCarer implements BearCleaner, BearFeeder { public void washTheBear() { //I think we missed a spot... } public void feedTheBear() { //Tuna Tuesdays... } }

And finally, we can leave the dangerous stuff to the crazy people:

public class CrazyPerson implements BearPetter { public void petTheBear() { //Good luck with that! } }

Going further, we could even split our BookPrinter class from our example earlier to use interface segregation in the same way. By implementing a Printer interface with a single print method, we could instantiate separate ConsoleBookPrinter and OtherMediaBookPrinter classes.

7. Dependency Inversion

The principle of Dependency Inversion refers to the decoupling of software modules. This way, instead of high-level modules depending on low-level modules, both will depend on abstractions.

To demonstrate this, let's go old-school and bring to life a Windows 98 computer with code:

public class Windows98Machine {}

But what good is a computer without a monitor and keyboard? Let's add one of each to our constructor so that every Windows98Computer we instantiate comes pre-packed with a Monitor and a StandardKeyboard:

public class Windows98Machine { private final StandardKeyboard keyboard; private final Monitor monitor; public Windows98Machine() { monitor = new Monitor(); keyboard = new StandardKeyboard(); } }

This code will work, and we'll be able to use the StandardKeyboard and Monitor freely within our Windows98Computer class. Problem solved? Not quite. By declaring the StandardKeyboard and Monitor with the new keyword, we've tightly coupled these 3 classes together.

Not only does this make our Windows98Computer hard to test, but we've also lost the ability to switch out our StandardKeyboard class with a different one should the need arise. And we're stuck with our Monitor class, too.

Let's decouple our machine from the StandardKeyboard by adding a more general Keyboard interface and using this in our class:

public interface Keyboard { }
public class Windows98Machine{ private final Keyboard keyboard; private final Monitor monitor; public Windows98Machine(Keyboard keyboard, Monitor monitor) { this.keyboard = keyboard; this.monitor = monitor; } }

Here, we're using the dependency injection pattern here to facilitate adding the Keyboard dependency into the Windows98Machine class.

Let's also modify our StandardKeyboard class to implement the Keyboard interface so that it's suitable for injecting into the Windows98Machine class:

public class StandardKeyboard implements Keyboard { }

Now our classes are decoupled and communicate through the Keyboard abstraction. If we want, we can easily switch out the type of keyboard in our machine with a different implementation of the interface. We can follow the same principle for the Monitor class.

Excellent! We've decoupled the dependencies and are free to test our Windows98Machine with whichever testing framework we choose.

8. Conclusion

In this tutorial, we've taken a deep dive into the SOLID principles of object-oriented design.

Vi började med en snabb bit av SOLID-historien och anledningarna till att dessa principer finns.

Bokstav för bokstav har vi brutit ner innebörden av varje princip med ett snabbt kodexempel som bryter mot det. Vi såg sedan hur vi fixar vår kod och får den att följa SOLID-principerna.

Som alltid finns koden tillgänglig på GitHub.