Diversity w polskim IT
Semyon Kirekov
Semyon KirekovFullstack Software Engineer @ MTS Group

Kiedy nie warto korzystać z setterów - na przykładzie Javy

Korzystanie z setterów w Javie to zło? Sprawdź, czy faktycznie warto używać ich w projektach.
21.06.20215 min
Kiedy nie warto korzystać z setterów - na przykładzie Javy

Settery cieszą się dużą popularnością w wielu językach programowania. Developerzy korzystali z nich już na tyle długo, że rozwiązanie to stało się czymś oczywistym. Co więcej, nawet IDE może wygenerować Ci settery - wystarczy, że klikniesz kilka klawiszy szybkiego dostępu i już. Niemniej jednak uważam, że powinniśmy unikać korzystania z setterów i pokażę Wam tutaj dlaczego. Artykuł ten zawiera przykłady z Javy, ale zasady te można stosować w jakimkolwiek innym języku programowania. 

Wydaje mi się, że każdy z nas widział coś takiego.

class Person {
  private int age;
  private String firstName;
  private String lastName;
  // getters and setters...
}

Person employee = new Person();
employee.setAge(25);
employee.setFirstName("Michael");
employee.setLastName("Brown");

Przykład użycia setterów w Javie

Powyższe podejście wygląda elegancko, ale zastanówmy się nad nim przez chwilę. Po pierwsze, tworzymy instancję pustego obiektu, a potem przypisujemy krok po kroku wartości. A co jeśli zapomnimy przypisać firstName albo age? Oznacza to, że przyszłe wykonania będą obsługiwać niekompletny obiekt. Co więcej, obecność setterów znaczy, że obiekt jest mutowalny i każdy może go zmodyfikować, kiedy tylko będzie chciał. Spójrzmy na kolejny przykład:

Person manager = new Person();
employee.setAge(32);
employee.setFirstName("Ellen");
employee.setLastName("Green");

List<Person> engineers = humanResources.findAllSubordinates(manager);
// some other actions with manager ...

Przekazywanie mutowalnego elementu do kolejnej metody 

Nie spodziewamy się żadnych zmian w obiekcie manager, jeśli wywołamy findAllSubordinates. Skąd możemy jednak wiedzieć na pewno? W takim przypadku humanResources może być tylko interfejsem i nie mamy pojęcia, jak działa. Nie wiemy więc, jaki stan w przyszłości przyjmie manager, bo jego publiczne API zawiera metody mutujące stan. 

Być może to Cię jeszcze nie przekonuje. Sprawdźmy kolejny przykład. Załóżmy, że mamy mapę z pracownikami, zawierającą ich stawki. 

Person jack = new Person();
jack.setName("Jack");
jack.setAge(23);

Person julia = new Person();
julia.setName("Julia");
julia.setAge(35);

Map<Person, Integer> map = new HashMap<>();
map.put(jack, 35);
map.put(julia, 65);

System.out.println("Map: " + map);
jack.setAge(55); // key corrupting
System.out.println("Corrupted map: " + map);
System.out.println("Jack's rate: " + map.get(jack));
System.out.println("Julia's rate: " + map.get(julia));

Niespodziewane popsucie mapy

A oto wynik:

Map: {[name=Jack, age=23]=35, [name=Julia, age=35]=65}
Corrupted map: {[name=Jack, age=55]=35, [name=Julia, age=35]=65}
Jack's rate: null
Julia's rate: 65


Dzieje się tak, ponieważ nowy hashcode dla Jacka nie równa się temu, który jest w mapie, więc mówimy tu o różnych kluczach. Istnieje jednak sztuczka, która pomoże nam naprawić sytuację, ale to temat na następny artykuł. 

Edit: Problem wystąpi w przypadku stworzenia method equals i hashCode. W innym przypadku stawka Jacka to 35, tak jak się spodziewaliśmy, ponieważ domyślna implementacja hashCode zwraca taką samą wartość, nawet jeśli zmienimy niektóre wartości pola. Dziękuję tw-abhi za zwrócenie uwagi!


Tak na marginesie, widziałem też, że ludzie korzystali z setterów przy wstrzykiwaniu zależności w takich frameworkach jak Spring. Sprawdźcie to:

@Component
class ParentService {
  ...
  @Autowired
  public void setChildService(ChildService childService) {
    this.childService = childService;
  }
}

@Component
class MyService {
  private ParentService parentService;
  ...
  public void corruptTheRuntime() {
    parentService.setChildService(null);
  }
}

Wstrzykiwanie zależności z setterem


Nikt się nie spodziewa, że zależność ParentService nagle zniknie. Może się tak jednak stać, nawet jeśli nie skorzystamy z Reflection API. Chodzi mi o to, że jeśli Twoja klasa ma metody, które mogą zmieniać stan, to może to doprowadzić do nieprzyjemnych konsekwencji. 

Co więc możemy zrobić? Najprościej będzie przekazać wszystkie wymagane wartości konstruktorowi. 

class Person {
  private final int age;
  private final String firstName;
  private final String lastName;
  
  public Person(int age, String firstName, String lastName) {
    this.age = age;
    this.firstName = firstName;
    this.lastName = lastName;
  }
  // getters...
}

Person mary = new Person(25, "Mary", "Watson");

Tworzenie instancji niemutowalnego obiektu z konstruktorem 


Widzisz, że wszystkie właściwości są teraz final? Jest to niezwykle ważne, bo oznacza, że wartości można przypisać tylko raz w zakresie konstruktora. Niemutowalność zapewnia nas, że obiekt się nie zmieni. Możemy go przekazać do jakiejkolwiek metody, a nawet uzyskać do niego dostęp z kilku wątków w bezpieczny sposób. 

“A co jeśli potrzebujemy domyślnych wartości?” No cóż, Java nie obsługuje domyślnych wartości argumentów, ale możemy zdefiniować kilka konstruktorów, pomijając przy tym parametry. Możemy też wykorzystać wzorzec Builder i zaimplementować go samemu, ale ja wolę wykorzystać do tego bibliotekę Lombok

// augo-generated builder and getters
@Builder
@Getter
class Person {
  private final int age;
  private final String firstName;
  @Builder.Default
  private final String lastName = "Brown";
}

Person mary = Person.builder()
                    .age(25)
                    .firstName("Mary")
                    .lastName("Watson")
                    .build();

Korzystanie z buildera do zastąpienia domyślnych wartości argumentów


No i teraz wiemy, który parametr został przypisany. Takie podejście może przypominać korzystanie z setterów, ale istnieje między tymi metodami spora różnica. Nie tworzymy instancji obiektu, dopóki nie wywołamy metody build, a jeśli go już stworzymy, to będzie on niemutowalny. 

Czasem developerzy korzystają z builderów do zastąpienia dużych konstruktorów. Może się to wydawać korzystne, bo sprawia, że kod jest czystszy i bardziej intuicyjny, ale warto też zwracać uwagę na przypadki, takie jak poniższy. Spójrzmy: 

@Getter
@AllArgsContrustor
@Builder
class ReallyHugeClass {
  private final int param1;
  private final int param2;
  ...
  private final int param10;
}

// Too many arguments. Hard to read and maintain.
ReallyHugeClass hugeness = new ReallyHugeClass(1, 32, 12, ..., 231);
// Much better now
ReallyHugeClass otherHugeness = ReallyHugeClass.builder()
                                               .param1(1)
                                               .param2(32)
                                               .param3(12)
                                               ...
                                               .param10(231);

Korzystanie z buildera do zastąpienia dużego konstruktora 


Chodzi o to, że to drugie podejście jest prostsze do zrozumienia, ale łatwo jest w nim zapomnieć o wielu argumentach. Dzieje się to dość często, zwłaszcza gdy jakąś klasę rozszerzono nowymi parametrami. Czasami da się to zaakceptować, a czasem nie. 

Nie zapominaj też, że pierwotny cel wzorca Builder to implementacja domyślnych wartości argumentów, a nie zastępowanie dużych konstruktorów. Jeśli Twoja klasa nie posiada wielu zależności i wszystkie z nich są wymagane, to lepiej byłoby podzielić klasę na kilka mniejszych. 

Podsumowanie

Mam nadzieję, że przekonałem Cię, iż wykorzystanie setterów nie jest dobrą praktyką. Jest jednak wiele sytuacji, w których musimy użyć setterów - jeśli np. jakaś biblioteka wystawia takie API. Moim zdaniem jednak nie warto korzystać z setterów w projekcie - nie opłaca się to.

Dziękuję za uwagę!

Źródła


Oryginał tekstu w języku angielskim możesz przeczytać tutaj.

<p>Loading...</p>