» Single Responsibility Principle (SRP)
Wstęp teoretyczny
„A class should have only one reason to change” – Klasa powinna mieć tylko jeden powód do zmiany (treść prawdopodobnie odrobinę sparafrazowana). Zdanie wzięte od pewnie jednego z najbardziej znanych osób traktujących o czystości kodu – Roberta C. Martina zwanego również Wujkiem Bobem. Przyznam, że na początku nie do końca rozumiałem, o co chodzi z tym „powodem do zmiany”. Po co klasa miałaby się zmieniać? Znaczy no, jeśli jest potrzeba – ten powód – to jasne, czemu nie. Ale dlaczego taki powód musiałby być jeden? Na szczęście na pewnej stronie (źródło), wyczytałem, że ten powód do zmiany nie jest wzięty w takim ujęciu, jak ja myślałem, hah! Klasa zwykle składa się z pól i metod, prawda? Zatem te „rzeczy” zwykle coś zmieniają albo są zmieniane (pola). Jeżeli są zmieniane, to w jakimś celu albo lepiej – z jakiegoś powodu. I to właśnie ten powód powinien być jeden na jedną klasę. Innymi słowy klasa powinna zajmować się jedną i tylko jedną specjalizacją.
Trochę bardziej praktycznie…
Wykorzystajmy pomysł z tworzeniem gry MMORPG z poprzedniego artykułu. Czyli standardowo są gracze (klasa Player), którzy powinni posiadać jakąś klasę umiejętności typu: wojownik, mag, itp. Weźmy sobie dla uproszczenia tylko jedną na początek. No ale przede wszystkim każdy gracz potrzebuje systemu levelowania postaci. Bez tego gra traci dużą część sensu, nie? Zatem tworzymy jedną klasę Player i tam wrzucamy wszystko, czego potrzebujemy:
public class Player { private int level = 0; private long experience = 0; private long nextLevelThreshold = 500; private Spell activeSpell = new Fireball(); /* PLAYER STATS */ public void showPlayerStats() { System.out.println("=== PLAYER STATISTICS ==="); System.out.println(" -> Level: " + level); System.out.println(" -> Experience: " + experience); System.out.println(" -> Next level threshold: " + nextLevelThreshold); System.out.println(); } public void addExperience(int experience) { this.experience += experience; while (isNextLevel()) { levelUp(); calculateNewThreshold(); } } private void calculateNewThreshold() { nextLevelThreshold = Math.round(nextLevelThreshold * 1.5); } private boolean isNextLevel() { return experience >= nextLevelThreshold; } private void levelUp() { level++; } /* END OF PLAYER STATS */ /* MAGE */ public void setActiveSpell(Spell activeSpell) { this.activeSpell = activeSpell; } public void castActiveSpell() { activeSpell.cast(); } /* END OF MAGIC */ }
Do zaklęć możemy wykorzystać pomysł z interfejsem z poprzedniego artykułu (wszystko na razie umieszczam w tej samej paczce):
public interface Spell { void cast(); } public class Fireball implements Spell { @Override public void cast() { System.out.println("FIREBALL!"); } } public class Slow implements Spell { @Override public void cast() { System.out.println("SLOW!"); } } public class DragonShout implements Spell { @Override public void cast() { System.out.println("FUS RO DAH!"); } }
Przypominam, że dzięki wspólnemu interfejsowi, cokolwiek nie znajdzie się w polu „activeSpell” (dopóki będzie to klasa implementująca interfejs Spells), będzie w porządku i będzie można wywoływać taką samą metodę „cast()”, z tym że inaczej będzie ona reagowała w zależności od tego, jaką klasę (zaklęcie) do tego pola wrzucimy. W naszym przypadku, co innego wyświetli na ekranie cast() z klasy Slow a co innego cast() z klasy DragonShout.
Ale wracając… Jak widzicie wszystko w jednym wydaje się dobrym rozwiązaniem, ale tylko na chwilę… Teraz, gdy będziemy chcieli dodać więcej klas…dla każdego gracza trzeba będzie robić oddzielną implementację. A gdybyśmy wyrzucili wszystko, co dotyczy klasy umiejętności „Mag” do oddzielnej klasy javowej „Mage” (i tak samo z innymi klasami umiejętności) dodatkowo połączyli to jednym interfejsem jak to zrobiliśmy z zaklęciami, to wystarczyłoby w nowoutworzone pole „skillClass” wrzucić instancję którejś z klas umiejętności i gotowe!
SkillClass skillClass = new Mage(); // lub SkillClassd skillClass = new Warrior();
Można by jeszcze przypisać odpowiednie metody z klasy Player do konkretnych przycisków na klawiaturze i voila! Podmiana na przykład „new Mage()” na „new Warrior()” skutkowałaby zmianą umiejętności, dajmy na to pod przyciskiem „k”, z „kuli ognia” na „głębokie cięcia” :). Tak samo powinniśmy enkapsulować wszystko, co związane jest z systemem levelowania. Gdybyśmy chcieli dodać nową klasę obok Playera jaką byłaby na przykład Monster i chcielibyśmy, żeby potwory też miały system levelowania, to w aktualnej sytuacji, trzeba byłoby zrobić kopiuj/wklej z klasy Player do klasy Monster. I potem każdą najdrobniejszą zmianę wprowadzać w dwóch miejscach… Niezbyt to komfortowe, nie? Dlatego właśnie każda klasa powinna się zajmować tylko swoją specjalizacją. Czyli klasa Mage wszystkim, co związane z klasą umiejętności „Mag”, klasa LevelingService wszystkim co związane z systemem levelowania, a klasa Player zarządzaniem wszystkim, co związane z graczem. Ale wszystko po kolei…
Kolejna wersja naszego fragmentu kodu mogłaby wyglądać jakoś tak:
public class Player { LevelingService levelingService = new LevelingService(); Mage mage = new Mage(); /* PLAYER STATS */ public void showPlayerStats() { levelingService.showPlayerStats(); } public void addExperience(int experience) { levelingService.addExperience(experience); } /* END OF PLAYER STATS */ /* MAGE */ public void setActiveSpell(Spell activeSpell) { mage.setActiveSpell(activeSpell); } public void castActiveSpell() { mage.castActiveSpell(); } /* END OF MAGIC */ } public class LevelingService { private int level = 0; private long experience = 0; private long nextLevelThreshold = 500; public void showPlayerStats() { System.out.println("=== PLAYER STATISTICS ==="); System.out.println(" -> Level: " + level); System.out.println(" -> Experience: " + experience); System.out.println(" -> Next level threshold: " + nextLevelThreshold); System.out.println(); } public void addExperience(int experience) { this.experience += experience; while (isNextLevel()) { levelUp(); calculateNewThreshold(); } } private void calculateNewThreshold() { nextLevelThreshold = Math.round(nextLevelThreshold * 1.5); } private boolean isNextLevel() { return experience >= nextLevelThreshold; } private void levelUp() { level++; } } public class Mage { private Spell activeSpell = new Fireball(); public void setActiveSpell(Spell activeSpell) { this.activeSpell = activeSpell; } public void castActiveSpell() { activeSpell.cast(); } } // PONIŻSZE KLASY JUŻ BEZ ZMIAN public interface Spell { void cast(); } public class Fireball implements Spell { @Override public void cast() { System.out.println("FIREBALL!"); } } public class Slow implements Spell { @Override public void cast() { System.out.println("SLOW!"); } } public class DragonShout implements Spell { @Override public void cast() { System.out.println("FUS RO DAH!"); } }
Dodatkowo pogrupowałem sobie cały materiał w paczki, żeby lepiej było się w nim poruszać.

Teraz wygląda to już o wiele lepiej. Każda klasa odpowiada za konkretne zadanie. Nowa klasa Monster będzie wyglądała identycznie jak klasa Player pod względem systemu levelowania.
public class Monster { LevelingService levelingService = new LevelingService(); public void showMonsterStats() { levelingService.showStats(); } public void addExperience(int experience) { levelingService.addExperience(experience); } }
Edit: Niestety w kodzie zapomniałem zmienić nazwę metody „showPlayerStats” na bardziej uniwersalną „showStats” i w repozytorium będzie ta pierwsza wersja (inna niż tu widzicie)! Miejcie to na uwadze!
Takim sposobem rozdzieliliśmy specjalizacje pomiędzy 3 klasy. Każda zajmuje się swoją i wszyscy są zadowoleni. 🙂 SRP została zachowana.
Linki do repozytorium na GitHubie:
- v1 – system levelowania i system zaklęć – wszystko w jednym
- v2 – system levelowania i system zaklęć – rozdzielone
- v3 – po dodaniu klasy Monster
- v4 – po dodaniu typu wyliczeniowego enumerate
Dodatkowo…* (niezwiązane bezpośrednio z tematem artykułu – można pominąć)
Zamknąłbym te wszystkie zaklęcia (a raczej ich nazwy) w jakiejś klasie, do której mógłbym się odwołać, żeby sprawdzić z czego mogę wybierać. Bo teraz to muszę przekopywać się przez strukturę katalogową całego projektu, żeby dowiedzieć się, ile i jakie mam zaklęcia do wyboru. Wg mnie ciekawym wyborem może być tzw. „Enum” – czyli typ wyliczeniowy. Wyglądałby on następująco:
public enum Spells { FIREBALL(new Fireball()), SLOW(new Slow()), DRAGON_SHOUT(new DragonShout()); private Spell spell; Spells(Spell spell) { this.spell = spell; } public void castSpell() { spell.cast(); } }
Wyrazy napisane CAPSLOCKiem można traktować jak instancje tego właśnie enuma o nazwie Spells, których „formularz” posiada tylko jedno prywatne pole o nazwie spell typu Spell, konstruktor ustawiający to jedyne pole oraz jedną metodę voidową o nazwie castSpell, która to wywołuje znaną już nam metodę cast() utworzonych przez nas zaklęć. Jeszcze bardziej analogicznie… Enum Spells można potraktować jak klasę z jednym polem, jednym konstruktorem i jedną metodą, a wyrazy napisane CAPSLOCKiem jak obiekty tej klasy, które są tworzone i przechowywane przez ich klasę macierzystą (enum Spells). Pobranie takiego obiektu realizowane jest tak, jak pobranie publicznego pola statycznego danej klasy:
Spells.FIREBALL;
Można też na takim obiekcie wywołać publiczną metodę:
Spells.FIREBALL.castSpell();
Poprawione klasy Mage i Player wyglądałyby tak:
public class Mage { private Spells activeSpell = Spells.FIREBALL; public void setActiveSpell(Spells activeSpell) { this.activeSpell = activeSpell; } public void castActiveSpell() { activeSpell.castSpell(); } } public class Player { // Zmienia się tylko metoda setActiveSpell - typ przekazywany z Spell na Spells public void setActiveSpell(Spells activeSpell) { mage.setActiveSpell(activeSpell); } }
A żeby sprawdzić jakie zaklęcia są dostępne wystarczy wpisać „Spells.” i jeśli nic się w Intelliju nie wyświetli, to można użyć skrótu [Ctrl + Space]. Otrzymujemy następującą listę:

Można również kliknąć LPM z przytrzymanym klawiszem Ctrl na „Spells”, co przeniesie nas do enuma o tej nazwie, w którym mamy wypisane wszystkie możliwe zaklęcia.
Kiedyś podczas pisania pluginów do Minecrafta natrafiłem również na takie zadanie, że potrzebowałem móc wpisywać zaklęcia z klawiatury (chociażby, żeby sprawdzać czy wszystko jest okej, ale potem stwierdziłem, że to ciekawe utrudnienie a zarazem „urzeczywistnienie” gry – taka swoista inkantacja zaklęć 🙂 ). Komendy użytkownika Minecraft zapisywał i przysyłał do mojej klasy głównej w postaci zmiennej tekstowej String. Potrzebowałem zatem jakiejś informacji o nazwie zaklęcia zamkniętej w zmiennej tego samego typu. Dlatego zmodyfikowałem trochę tego enuma a także i samą klasę Mage:
public enum Spells { FIREBALL(new Fireball(), "Fireball"), SLOW(new Slow(), "Slow"), DRAGON_SHOUT(new DragonShout(), "DragonShout"); private Spell spell; private String spellName; Spells(Spell spell, String spellName) { this.spell = spell; this.spellName = spellName; } public void castSpell() { spell.cast(); } public String getSpellName() { return spellName; } } public class Mage { private Spells activeSpell = Spells.FIREBALL; public void setActiveSpell(String activeSpell) { boolean isSpellFound = false; for (Spells spellFromEnum : Spells.values()) { if (spellFromEnum.getSpellName().equals(activeSpell)) { this.activeSpell = spellFromEnum; isSpellFound = true; } } if (!isSpellFound) { System.out.println("No such spell!"); } } public void castActiveSpell() { activeSpell.castSpell(); } }
Dzięki takiemu rozwiązaniu, kiedy użytkownik wpisuje w konsoli Minecrafta nazwę zaklęcia, to algorytm pobiera wszystkie rekordy z enuma Spells metodą values() (która zwraca odpowiedź w formie kolekcji) po czym spróbuje wyszukać taki, który posiada pole „spellName” typu String, w którym zamknięty jest tekst dokładnie taki jaki podał w konsoli użytkownik. I jeśli znajdzie, to do pola „activeSpell” swojego obiektu przypisze ten znaleziony rekord (np. FIREBALL), a jeśli nie, to wypisze w konsoli „No such spell!”. – całkiem ciekawe ćwiczenie poprawnego pisania, niczym zmodyfikowana wersja „Mistrza Klawiatury”, hahah!
Dzięki wielkie za uwagę! Dobrego dnia!
All men have stars, but they are not the same things for different people. For some, who are travelers, the stars are guides. For others they are no more than little lights in the sky. For others, who are scholars, they are problems… But all these stars are silent. You-You alone will have stars as no one else has them.
-- Little Prince --