Wybierz region
pl
  • PL
  • EN
Wydrukuj

Design Patterns w Javie

Design Patterns Java

Poniższy artykuł ma na celu, przedstawienie drobnych zmian, które możemy wprowadzić pracując z różnymi wzorcami projektowymi, aby uczynić kod jeszcze lepszym.

Szybkie przypomnienie czym są patterny

Design Patterns to sprawdzone rozwiązania problemów projektowych, które pojawiają się podczas tworzenia oprogramowania. Są to swoiste wzorce architektoniczne, które zostały opracowane i udokumentowane przez doświadczonych programistów i projektantów. 

Design Patterns mają na celu ułatwienie rozwoju oprogramowania poprzez zapewnienie sprawdzonych struktur, relacji i mechanizmów, które można wielokrotnie stosować w różnych kontekstach. Oferują one jasne wytyczne dotyczące organizacji kodu, komunikacji między obiektami i rozwiązania konkretnych problemów projektowych.

Dwie proste zasady dla użycia Optionals

Java Optionals są dostępna od Javy 1.8. Jego zadaniem jest chronienie nas przed otrzymaniem wartości null w projekcie, dodatkowo jego API umożliwia schludne obsłużenie sytuacji w której nie ma zasobu. Pracując przy projektach, często używam “ślepe” wykorzystanie Optionals. Jest to pierwszy problem który chciałbym zaadresować. 

Zacznijmy od przykładu prostej klasy:

class Cart {

private Optional<Vegetables> vegetables;

public Vegetables getVegetables() {

return vegetables;

}

}

Dokładając metodę umożliwiającą zmianę naszego obiektu umieszczonego w Optionalu, moglibyśmy dodać metodę ustawiającą obiekt vegetables.

public void changeVegetables(Optional<Vegetables> vegetables) {

LOGIKA

this.vegetables = vegetables;

}

Co przy późniejszym pisaniu może prowadzić do bardzo brzydko pachnącego kodu:

cart.changeVegetables(Optional.of(carrots));

cart.changeVegetables(Optional.empty());

W powyższym wywołaniu metody, nie mamy klarowności co się dzieje. Mamy niepotrzebne owinięcie obiektu w Optional, oraz możemy “zzerować” obiekt za pomocą metody której nazwa niekoniecznie sugeruje takie zachowanie.  W takim przypadku wygodniej przepisać to na dwie funkcję:

public void changeVegetables(Vegetables vegetables) {...   LOGIKA}

public void removeVegetables() {... LOGIKA}

Wyklarowały nam się dwie proste zasady, które powinniśmy stosować przy używaniu Optionals:

1) Używać do owrapowania pola w sytuacji gdy obiekt może nie istnieć

2) Nie używać w wejściach metod. Może to sugerować wady projektowe. 

Co do zasady 2:

Metoda dopuszczająca że obiekt może istnieć lub nie moim zdaniem nie ma sensu. Często wywołanie metody z brakiem parametru, wymusza inną logikę w metodzie. Co już sugeruje rozbicie jej na dwie oddzielne metody. Dodatkowym problemem jest brak jawności czy używamy zachowania z parametrem czy bez. Lepiej jawnie określić wykorzystanie metody 

Po tym lekkim wstępie możemy przejść do bardziej ciekawych rozwiązań.

Strategy Pattern

Strategy pattern (wzorzec strategii) to jeden z wzorców projektowych stosowanych w programowaniu obiektowym. Jest on częścią tzw. wzorców projektowych behawioralnych, które opisują sposoby komunikacji i organizacji obiektów.

 Strategy pattern umożliwia definiowanie zestawu różnych algorytmów lub strategii i umożliwia ich wymienianie w trakcie działania programu. Dzięki temu wzorzecowi można oddzielić implementację konkretnych algorytmów od kodu klienta, co ułatwia zarządzanie i rozwijanie systemu. Chciałbym pokazać jak można go rozwinąć stosująć Lambda Expresion. 

Prosty przykład:Dla uproszczenia, mamy listę liczb reprezentujących wiek osób uczestniczących na spotkaniu:

var ages = List.of(18, 15, 18,24,33,66,10.12);

Załóżmy że dostajemy zadanie obliczenia średniej wieku osób uczestniczących, i nie mamy czasu na przemyślenie realizacji. Moglibyśmy napisać poniższy mało czytelny kod:

public static int averageAge(List<Integer> values) {

var result = 0;

for(var value : values) {

result += value;

}

return result/values.size();

}

Ewentualnie występujące błędy pomijam jako że nie są istotne dla problemu.

Następnie dostajemy zadanie na obliczenie średniego wieku osób pełnoletnich.

Najszybciej skopiować już istniejącą funkcję i zmodyfikować warunek:

public static int averageAgeOlderThan18(List<Integer> values) {

var result = 0;

var count = 0;

for(var value : values) {

if(value >= 18) {result += value};

count++;

}

return result/count;

}

Czas mija, wszyscy zapomnieli o kodzie, pojawia się zapotrzebowanie na wynik liczenia średniej wieku osób niepełnoletnich. Gdy trzeci raz kopiujemy kod by dodać funkcjonalność możemy być pewni że da się to zrobić lepiej.

Od Javy 8 mamy dostęp do:

import java.util.function.Predicate;

W Javie interfejs Predicate<T> jest częścią pakietu java.util.function i służy do reprezentowania predykatów, czyli funkcji logicznych przyjmujących jeden argument i zwracających wartość logiczną true lub false. Interfejs Predicate jest często wykorzystywany w operacjach filtrujących i sprawdzających warunki w kolekcjach oraz strumieniach.

Przy pomocy podania funkcji jako argumentu metody możemy ładnie zrefaktoryzować nasz wcześniejszy kod podając Predicate na wejściu metody:

public static int averageAge(List<Integer> values, Predicate<Integer> selector) {

 

var result = 0;

var count = 0;

 

for (var value : values) {

 

if (selector.test(value)) {

result += value;

count++;

}

}

return result / count;

}

Co umożliwi nam wykorzystanie metody w zależności od zaistniałych warunków:

averageAge(ages, e -> true);

averageAge(ages, e -> e >= 18);

Podanie funkcji lambda jako argumentu (e -> e >= 18)  umożliwia generalizację metody. Możemy przedstawić to jeszcze czytelnie poprzez wyniesienie warunku do oddzielnej metody:

public static boolean idOlderThan18 (int number) {

return number >= 18;

}

 

averageAge(ages, Test::idOlderThan18);

Podsumowując.

Wyrażenia lambda przy używaniu Strategy Pattern zmniejszy nam ilość kodu którą musimy napisać.

Decorator pattern

 Przypominając decorator pattern, pozwala ona dodawać nowe funkcjonalności do istniejących obiektów dynamicznie, bez konieczności modyfikacji ich struktury. Wzorzec ten umożliwia tworzenie elastycznych i rozszerzalnych systemów poprzez składanie obiektów w hierarchie dekoratorów.Jednym z niezachęcających przykładów tego patternu jest następujący kod:

public static void main(String[] args) {

new DataInputStream(

new BufferedInputStream(

new FileInputStream(...)

)

)

Wygląda mało czytelnie i nie intuicyjnie. Pattern ma swoje zalety, daje nam on możliwość wyciągnięcia głównego zachowania aplikacji i nadania jej “smaku” w zależności co użytkownik będzie potrzebował. Pytanie, jak wykorzystać zalety tego patternu bez  użycia wyżej pokazanej struktury.

Przedstawię to na przykładzie z którym spotkałem się w internecie, porównując dany pattern do aparatu. Naszym “core” programu jest kamera, zaś “smakiem” są obiektywy, które użytkownik może sobie dobrać w zależności od potrzeb.

Zacznijmy od zdefiniowania core programu:

public class Camera {

public Color snap(Color input) {

return input;

}

}

Przy wywołaniu:

camera.snap(new Color(50,50,50)):

Nasz core programu przekazuje wyjście na wyjście. Chcielibyśmy teraz dodać różne filtry zmieniające dane.

public class Camera {

Function<Color, Color> filter = input -> input;

public Color snap(Color input) {

return filter.apply(input);

}

}

W tym przypadku nasz filtr przekazuje wejście na wyjściei, ale wystarczy że dodamy konstruktor:

public Camera (Function<Color, Color> filter) {

this.filter = filter

}

Możemy zmieniać w przyjemny sposób naszą funkcjonalność istniejącego obiektu w zależności od potrzeb użytkownika kodu.

new Camera(input -> input.brighter());

Istotnym elementem ułatwiającym pracę przy takim projektowaniu logiki jest możliwość wywołania wielu funkcji. Mając to mam na myśli funkcję agregującą:

Function<Integer, Integer> inc = e -> e + 1;

Function<Integer, Integer> doubleIt = e -> e * 2;

Function<Integer, Integer> combined = inc.andThen(doubleIt);

Funkcja combined najpierw podniesie wartość wejścia o jeden, a następnie pomnoży wynik przez dwa. W naszym przykładzie z kamerą zmienimy konstruktor by akceptował wiele “filtrów”:

public Camera (Function<Color, Color>... filters) {

filter = input -> input;

for(var filter : filters) {

filter = filter.andThen(filter)

}

}

W ten sposób przy tworzeniu nowego obiektu będziemy mogli określić jego działanie nie ograniczając się do jednego filtru.

Kończąc ten przykład refakturujemy konstruktor do docelowego kodu:

filter = Stream.of(filters)

.reduce(input -> input, (combinedFilter, filter) ->

combinedFilter.andThen(filter));

Podsumowując.

W niektórych przypadkach używanie funkcji jako dekoratorów może znacząco polepszyć czytelność oraz przyjemność z pracy z kodem.

Execute around method pattern

Execute Around Method Pattern (czasem nazywany także Execute Around Pattern lub Resource Acquisition Is Initialization - RAII) jest wzorcem projektowym, który pochodzi głównie z języka programowania C++. Polega na zapewnieniu poprawnego i bezpiecznego zarządzania zasobami poprzez wykorzystanie konstrukcji bloku kodu (block of code) lub funkcji, która automatycznie zarządza otwarciem i zamknięciem zasobów.

Załóżmy że mamy klasę Resource która zawiera jakiś zewnętrzny zasób:

class Resource {

public Resource() { ... }

public Resource r1() {

System.out.printf("R1");

return this;

}

public Resource r2() {

System.out.printf("R2");

return this;

}

}

Gdy wywołamy jeden z zasobów:

Resource resource = new Resource();

resource.r1();

resource.r2();

Powinniśmy zadbać o to by garbage collector wyczyścił zasoby po ich użyciu.

W javie garbage collector zadziała automatycznie ale nie natychmiast. Nie wiemy kiedy pamięć zostanie zwolniona. Moglibyśmy dodać metoda finalize:

public void finalize() {

System.out.printf("Clean");

}

W tym przypadku jednak metoda nie zostanie wywołana. Przy dużej dostępnej ilości pamięci będziemy kumulować zasoby tak długo aż garbage collector nie uzna że należy coś z tym zrobić. Nie jest to optymalne rozwiązanie. Jest to jeden z powodów dla których od Javy 9 metoda finalize jest deprecated. Można rozwiązać ten problem za pomocą try with resources, ale jest jeszcze jedno podejście.

Zastąpmy finalize:

public void close() {

System.out.printf("Clean");

}

I dodajmy:

public static void use(Consumer<Resource> block) {

Resource resource = new Resource();

try{

block.accept(resource)

} finally {

resource.close();

}

}

Umożliwi nam to wywołanie metody use, przekazanie bloku kodu, stworzenie zasobu, a na końcu zamknięcie zasobów. Wywołanie resource wyglądałoby następująco:

Resource.use(resource ->

resource.r1());

Przy tym wywołaniu po zakończeniu pracy z zasobami, zamkniemy daną sekcję. Plusem tego rozwiązania jest przede wszystkim to że nie musimy pamiętać o np. zamykaniu zasobów, gdyż sam kod jest tak zaprojektowany, że się tym zajmie.

Podsumowanie

Wraz z dodawaniem nowych rozwiązań do języków programowania, pojawiają się nowe ciekawsze implementacje rozwiązań znanych wzorców projektowych. Co jakiś czas warto odświeżyć aktualne nowinki.

 

 


Cezary Hudzik

Ukończył Politechnikę Gdańską na wydziale ETI. Z zamiłowania interesuje się sztucznymi sieciami neuronowymi oraz uczeniem maszynowym. W wolnych chwilach jeździ motocyklem po pomorskich drogach lub pije wino.


Wydrukuj