Solidny przewodnik po solidnych zasadach

1. Wstęp

W tym samouczku omówimy zasady SOLID w projektowaniu obiektowym.

Najpierw zaczniemy od zbadania przyczyn ich powstania i powodów, dla których powinniśmy wziąć je pod uwagę podczas projektowania oprogramowania. Następnie opiszemy każdą zasadę wraz z przykładowym kodem, aby podkreślić tę kwestię.

2. Przyczyna SOLIDNYCH zasad

Zasady SOLID zostały po raz pierwszy konceptualizowane przez Roberta C. Martina w jego artykule z 2000 r. „ Zasady projektowania i wzorce projektowe”. Koncepcje te zostały później rozwinięte przez Michaela Feathersa, który wprowadził nas w akronim SOLID. W ciągu ostatnich 20 lat tych 5 zasad zrewolucjonizowało świat programowania obiektowego, zmieniając sposób, w jaki piszemy oprogramowanie.

Czym więc jest SOLID i jak pomaga nam pisać lepszy kod? Mówiąc najprościej, zasady projektowania Martina i Feathersa zachęcają nas do tworzenia łatwiejszego w utrzymaniu, zrozumiałego i elastycznego oprogramowania . W konsekwencji, wraz ze wzrostem rozmiarów naszych aplikacji, możemy zmniejszyć ich złożoność i zaoszczędzić sobie wielu kłopotów w przyszłości!

Poniższe 5 koncepcji składa się na nasze SOLIDNE zasady:

  1. S Ingle odpowiedzialności
  2. O długopis / zamknięte
  3. L iskov Podstawienie
  4. I nterface Segregacja
  5. Odwrócenie D ependencji

Chociaż niektóre z tych słów mogą brzmieć zniechęcająco, można je łatwo zrozumieć za pomocą kilku prostych przykładów kodu. W kolejnych sekcjach przyjrzymy się dokładniej, co oznacza każda z tych zasad, wraz z krótkim przykładem Java, aby zilustrować każdą z nich.

3. Pojedyncza odpowiedzialność

Zacznijmy od zasady pojedynczej odpowiedzialności. Jak możemy się spodziewać, zasada ta mówi, że klasa powinna mieć tylko jedną odpowiedzialność. Co więcej, powinien mieć tylko jeden powód do zmiany.

W jaki sposób ta zasada pomaga nam tworzyć lepsze oprogramowanie? Zobaczmy kilka z jego zalet:

  1. Testowanie - klasa z jedną odpowiedzialnością będzie miała znacznie mniej przypadków testowych
  2. Niższe sprzężenie - mniejsza funkcjonalność w pojedynczej klasie będzie miała mniej zależności
  3. Organizacja - mniejsze, dobrze zorganizowane klasy są łatwiejsze do wyszukania niż te monolityczne

Weźmy na przykład klasę przedstawiającą prostą książkę:

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

W tym kodzie przechowujemy nazwę, autora i tekst powiązany z wystąpieniem książki .

Dodajmy teraz kilka metod odpytywania tekstu:

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

Teraz nasza klasa Book działa dobrze i możemy przechowywać dowolną liczbę książek w naszej aplikacji. Ale po co jest przechowywanie informacji, jeśli nie możemy wysłać tekstu do naszej konsoli i przeczytać go?

Rzućmy ostrożność na wiatr i dodajmy metodę drukowania:

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

Kodeks narusza jednak zasadę pojedynczej odpowiedzialności, którą nakreśliliśmy wcześniej. Aby naprawić nasz bałagan, powinniśmy zaimplementować osobną klasę, która zajmuje się tylko drukowaniem naszych tekstów:

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

Niesamowite. Nie tylko opracowaliśmy klasę, która zwalnia Book z jej obowiązków związanych z drukowaniem, ale możemy również wykorzystać naszą klasę BookPrinter do wysyłania naszego tekstu na inne media.

Niezależnie od tego, czy chodzi o e-mail, logowanie czy cokolwiek innego, mamy osobną klasę poświęconą temu jednemu problemowi.

4. Otwarte na rozszerzenie, zamknięte na modyfikacje

Teraz czas na „O” - bardziej formalnie znaną jako zasada otwarte-zamknięte . Mówiąc najprościej, klasy powinny być otwarte na rozszerzenie, ale zamknięte na modyfikacje. Robiąc to, powstrzymujemy się od modyfikowania istniejącego kodu i powodowania potencjalnych nowych błędów w skądinąd przyjemnej aplikacji.

Oczywiście jedynym wyjątkiem od reguły jest naprawianie błędów w istniejącym kodzie.

Przyjrzyjmy się tej koncepcji dokładniej za pomocą szybkiego przykładu kodu. W ramach nowego projektu wyobraź sobie, że zaimplementowaliśmy klasę Guitar .

Jest w pełni rozwinięty i ma nawet pokrętło głośności:

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

Uruchamiamy aplikację i wszyscy ją kochają. Jednak po kilku miesiącach stwierdzamy, że gitara jest trochę nudna i przydałaby się jej niesamowity wzór płomienia, aby wyglądała bardziej „rock and roll”.

W tym miejscu może być kuszące, aby po prostu otworzyć klasę Guitar i dodać wzór płomienia - ale kto wie, jakie błędy mogą pojawić się w naszej aplikacji.

Zamiast tego trzymajmy się zasady open-closed i po prostu rozszerzmy naszą klasę Guitar :

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

Rozszerzając klasę Guitar możemy być pewni, że nasza dotychczasowa aplikacja nie ulegnie zmianie.

5. Zastępstwo Liskova

Następna na naszej liście jest podstawienie Liskova, które jest prawdopodobnie najbardziej złożoną z 5 zasad. Po prostu, jeśli klasa jest podtypem klasy B , powinniśmy być w stanie zastąpić B z A bez zakłócania zachowania naszego programu.

Przejdźmy od razu do kodu, aby pomóc nam zrozumieć tę koncepcję:

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

Powyżej definiujemy prosty interfejs samochodu z kilkoma metodami, które powinny być w stanie spełnić wszystkie samochody - włączanie silnika i przyspieszanie do przodu.

Zaimplementujmy nasz interfejs i dostarczmy trochę kodu dla metod:

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

Jak opisuje nasz kod, mamy silnik, który możemy włączyć i zwiększyć moc. Ale czekaj, jest rok 2019, a Elon Musk był zajętym człowiekiem.

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.

Zaczęliśmy od krótkiej historii SOLID i powodów, dla których istnieją te zasady.

Litera po literze wyjaśniliśmy znaczenie każdej zasady za pomocą szybkiego przykładu kodu, który ją narusza. Następnie zobaczyliśmy, jak naprawić nasz kod i dostosować go do zasad SOLID.

Jak zawsze kod jest dostępny w serwisie GitHub.