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));
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
ihashCode
. W innym przypadku stawka Jacka to 35, tak jak się spodziewaliśmy, ponieważ domyślna implementacjahashCode
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.