Твърдо ръководство за ТВЪРДИ Принципи

1. Въведение

В този урок ще обсъдим SOLID принципите на обектно-ориентирания дизайн.

Първо ще започнем с изследването на причините, поради които са възникнали, и защо трябва да ги вземем предвид при проектирането на софтуер. След това ще очертаем всеки принцип заедно с примерния код, за да подчертаем въпроса.

2. Причината за ТВЪРДИТЕ Принципи

Принципите SOLID са концептуализирани за първи път от Робърт С. Мартин в неговия доклад от 2000 г. „ Принципи на проектиране и модели на проектиране“. По-късно тези концепции са надградени от Майкъл Федърс, който ни запозна със съкращението SOLID. И през последните 20 години тези 5 принципа революционизираха света на обектно-ориентираното програмиране, променяйки начина, по който пишем софтуер.

И така, какво е SOLID и как ни помага да напишем по-добър код? Просто казано, принципите на дизайна на Martin и Feathers ни насърчават да създаваме по-поддържаем, разбираем и гъвкав софтуер . Следователно, с увеличаване на размерите на нашите приложения, ние можем да намалим тяхната сложност и да си спестим много главоболия по-нататък!

Следните 5 концепции съставляват нашите ТВЪРДИ принципи:

  1. S огън Отговорност
  2. O писалка / Затворена
  3. L iskov Замяна
  4. Аз разбирам сегрегацията
  5. D ependency Инверсия

Въпреки че някои от тези думи може да звучат обезсърчително, те могат лесно да бъдат разбрани с някои прости примери за кодове. В следващите раздели ще се задълбочим в това, което означава всеки от тези принципи, заедно с бърз пример за Java, който илюстрира всеки един.

3. Единична отговорност

Нека започнем нещата с принципа на единната отговорност. Както бихме могли да очакваме, този принцип гласи, че даден клас трябва да има само една отговорност. Освен това трябва да има само една причина да се промени.

Как този принцип ни помага да изградим по-добър софтуер? Нека видим няколко от неговите предимства:

  1. Тестване - Клас с една отговорност ще има много по-малко тестови случаи
  2. По-ниско свързване - По-малко функционалност в един клас ще има по-малко зависимости
  3. Организация - По-малките, добре организирани класове са по-лесни за търсене, отколкото монолитните

Вземете например клас, който да представлява проста книга:

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

В този код съхраняваме името, автора и текста, свързани с екземпляр на книга .

Нека сега добавим няколко метода за заявка към текста:

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); } }

Сега класът ни за книги работи добре и можем да съхраняваме толкова книги, колкото ни харесва в нашето приложение. Но каква полза от съхраняването на информацията, ако не можем да изведем текста на конзолата си и да я прочетем?

Нека хвърлим внимание на вятъра и добавим метод за печат:

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

Този кодекс обаче нарушава принципа на единна отговорност, който посочихме по-рано. За да оправим нашата бъркотия, трябва да внедрим отделен клас, който се занимава само с отпечатването на нашите текстове:

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.. } }

Страхотно. Не само, че разработихме клас, който освобождава Книгата от нейните задължения за печат, но също така можем да използваме нашия клас BookPrinter, за да изпращаме текста си на други носители.

Независимо дали става въпрос за имейл, регистриране или нещо друго, ние имаме отделен клас, посветен на тази една грижа.

4. Отваря се за разширение, затваря се за промяна

Сега е време за „О“ - по-официално известен като принцип на отворено-затворено . Просто казано, класовете трябва да бъдат отворени за разширение, но затворени за модификация. По този начин се спираме да променяме съществуващия код и да причиняваме потенциални нови грешки в иначе щастливо приложение.

Разбира се, единственото изключение от правилото е при поправяне на грешки в съществуващ код.

Нека разгледаме концепцията допълнително с бърз пример за код. Като част от нов проект, представете си, че сме внедрили клас по китара .

Той е напълно готов и дори има копче за сила на звука:

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

Стартираме приложението и всички го обичат. След няколко месеца обаче решихме, че китарата е малко скучна и може да се справи със страхотен модел на пламък, за да изглежда малко по-„рокендрол“.

В този момент може да е изкушаващо просто да отворите класа по китара и да добавите модел на пламък - но кой знае какви грешки може да възникнат в нашето приложение.

Вместо това нека се придържаме към принципа отворено-затворено и просто да разширим класа си по китара :

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

Разширявайки класа на китара, можем да бъдем сигурни, че съществуващото ни приложение няма да бъде засегнато.

5. Замяна на Лисков

Следващото място в нашия списък е заместването на Лисков, което е може би най-сложният от 5-те принципа. Просто казано, ако клас A е подтип на клас B , тогава би трябвало да можем да заменим B с A, без да нарушаваме поведението на нашата програма.

Нека просто да преминем направо към кода, за да помогнем да обгърнем главата си около тази концепция:

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

По-горе дефинираме прост автомобилен интерфейс с няколко метода, които всички автомобили трябва да могат да изпълнят - включване на двигателя и ускоряване напред.

Нека внедрим нашия интерфейс и предоставим код за методите:

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); } }

Както нашият код описва, ние имаме двигател, който можем да включим и да увеличим мощността. Но изчакайте, това е 2019 г., а Илон Мъск беше зает човек.

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.

Ние започнахме с един бърз малко солидна история и причините да съществуват тези принципи.

Буква по буква, ние разделихме значението на всеки принцип с бърз пример за код, който го нарушава. След това видяхме как да поправим нашия код и да го накараме да се придържа към принципите SOLID.

Както винаги, кодът е достъпен в GitHub.