Wyobraźmy sobie, że znajdujemy w projekcie taki kod w Javie (lub prawie identyczny — w Kotlinie):
public void assertNoViolations(Collection<ConstraintViolation> violations) { if (!isEmpty(violations)) { throw new IllegalStateException("violates constraints: " + violations); } }
Na oko — kod jak kod, robi co trzeba, nuda. Co może pójść nie tak?
Metoda zgodnie z oczekiwaniami bezproblemowo akceptuje nulla. Rzuca wyjątkiem, gdy przekażemy jej listę zawierającą contraint violations. Jest jeden szkopuł:
assertNoViolations(emptyList());
java.lang.IllegalStateException: violates constraints: []
Jak to jest możliwe?
Statyczne importy to jeden z kontrowersyjnych elementów składni Javy, o ich stosowaniu lub nie toczą się debaty na blogach i StackOverflow. Zazwyczaj jako uzasadnione użycie przytacza się statyczny import assertEquals/assertThat
w testach, żeby zyskać na czytelności. Od Javy 8 statyczne importy wchodzą dużo mocniej w główny kod projektu, a to ze względu na streamy. Możemy pisać „na piechotę”:
var datesByYear = dates.stream() .collect(Collectors.groupingBy( d -> d.get(ChronoField.YEAR), Collectors.mapping( d -> d.format(DateTimeFormatter.ISO_DATE_TIME), Collectors.toList())) );
ale użycie statycznych importów powoduje, że kod staje się zauważalnie bardziej przejrzysty i deklaratywny:
var datesByYear = dates.stream() .collect(groupingBy( d -> d.get(YEAR), mapping( d -> d.format(ISO_DATE_TIME), toList())) );
Statycznych importów w ten sposób używa nawet dokumentacja API streamów.
Podobny mechanizm na dostanie się bezpośrednio do metody istnieje też w Kotlinie, choć tam nie mówi się o statycznych importach — to po prostu importy. Poza pominięciem słowa „static” w używanym określeniu wszystko wygląda w zasadzie identycznie jak w Javie.
Skoro w powyższym kodzie używającym streamów statyczne importy przynoszą nam korzyść, czemu nie mamy wycisnąć z nich maksimum, importować tak wszędzie, gdzie zyskamy czytelność i kod bliższy zdaniom w języku naturalnym? Tu wracamy do fragmentu z początku artykułu :
if (!isEmpty(violations)) {
(poniższa linijka będzie wyglądała identycznie w Kotlinie)
Czyta się to bardziej naturalnie niż CollectionUtils.isEmpty(violations)
. Problem tkwi w tym, czego nie widać: skąd pochodzi metoda isEmpty. Dużo klas na naszym classpathie deklaruje metody o takiej nazwie i jeśli mamy pecha importując metodę StringUtils.isEmpty z powszechnie używanej biblioteki spring-core dojdziemy do zwodniczego zachowania opisanego we wstępie.
Jeśli na spokojnie zastanowić się nad tym feralnym importem ze StringUtils, powinniśmy krzyknąć: ale jakim cudem się to kompiluje? Pchamy przecież kolekcję do utila od stringów, w języku z silnym typowaniem.
Niestety autorzy Springa zadeklarowali tę metodę jako
public static boolean isEmpty(Object str)
przez co mamy na naszym classpathie wyrwę w systemie kontroli typów czekającą, aż w nią wpadniemy. Weźmie bez skarg wszystko, co tylko przekażesz, ale zadziała poprawnie tylko dla stringów. Taka sygnatura jest zdaje się traktowana przez autorów jako feature niż bug — pewnie ze względu na SpEL i inne zabawy z parsowaniem i ładowaniem kontekstu aplikacji. Nie liczyłbym, że się zmieni w najbliższej przyszłości.
Co zrobić? Jak żyć?
Można przyjąć ryzyko na klatę i dalej trzymać się w naszym projekcie konwencji, że importujemy statycznie wszystko, co poprawi czytelność — ufamy, że nasze testy automatyczne i code review wyłapią błędy. Takich przypadków jak metoda isEmpty()
nie powinno być w końcu wiele.
Jeśli uważamy, że faktycznie wystarczy uniknąć nielicznych pułapek, możemy zdecydować się na automatyczne ich wykrywanie. Niektórzy argumentują, że należy odciążać ludzi przy code review i sprawdzanie prostych reguł zrzucać na komputery. CheckStyle ma regułę AvoidStaticImport, ale działa odwrotnie, niż byśmy chcieli: przyjmuje listę miejsc, z których wolno importować, a nie czarną listę. Użytkownicy Kotlina mogą skorzystać z reguły ForbiddenImport narzędzia Detekt. Jest ona jednak traktowana jako problem ze „stylem”, nie przerywa sama w sobie buildu i nie da się tego przekonfigurować.
Inna opcja to przyjęcie, że nie da się określić uniwersalnej i prostej reguły, bo sprawa jest zbyt subiektywna. Skoro tak, to odpuszczamy sobie statyczne importy w sytuacji, gdy sama nazwa statycznej metody (bez klasy przed nią) nie daje jednoznacznej informacji, z czym mamy do czynienia. Czyli: kolektory z biblioteki standardowej tak (bo mają wystarczająco unikalne nazwy), ale już CośtamUtil.isEmpty()
albo Optional.of()
nie. Do takiej konwencji zdaje się stosować Oracle w dokumentacji: wracając do Collectors.toMap() mamy w przykładowym kodzie zaimportowaną statycznie metodę toMap()
, ale już nie Function.identity()
.