JavaRush /Blog Java /Random-PL /FindBugs pomaga lepiej nauczyć się języka Java
articles
Poziom 15

FindBugs pomaga lepiej nauczyć się języka Java

Opublikowano w grupie Random-PL
Statyczne analizatory kodu są popularne, ponieważ pomagają znaleźć błędy powstałe na skutek nieuwagi. Ale o wiele bardziej interesujące jest to, że pomagają korygować błędy powstałe z niewiedzy. Nawet jeśli wszystko jest napisane w oficjalnej dokumentacji języka, nie jest faktem, że wszyscy programiści przeczytali ją uważnie. A programiści mogą to zrozumieć: będziesz zmęczony czytaniem całej dokumentacji. Pod tym względem analizator statyczny jest jak doświadczony przyjaciel, który siedzi obok ciebie i patrzy, jak piszesz kod. Nie tylko mówi: „Tutaj popełniłeś błąd przy kopiowaniu i wklejaniu”, ale także: „Nie, nie możesz tak pisać, sam spójrz do dokumentacji”. Taki przyjaciel jest bardziej przydatny niż sama dokumentacja, ponieważ podpowiada tylko te rzeczy, z którymi faktycznie spotykasz się w swojej pracy, a milczy na temat tych, które nigdy Ci się nie przydadzą. W tym poście opowiem o niektórych zawiłościach Java, których nauczyłem się, korzystając z analizatora statycznego FindBugs. Być może dla Ciebie również niektóre rzeczy będą nieoczekiwane. Ważne jest, aby wszystkie przykłady nie miały charakteru spekulatywnego, ale opierały się na prawdziwym kodzie.

Operator trójskładnikowy?:

Wydawać by się mogło, że nie ma nic prostszego niż operator trójskładnikowy, ma on jednak swoje pułapki. Myślałam, że między projektami nie ma zasadniczej różnicy, Type var = condition ? valTrue : valFalse; a Type var; if(condition) var = valTrue; else var = valFalse; okazało się, że jest tu pewna subtelność. Ponieważ operator trójskładnikowy może być częścią wyrażenia złożonego, jego wynik musi być konkretnym typem określonym w czasie kompilacji. Zatem, powiedzmy, z warunkiem prawdziwym w formie if kompilator prowadzi valTrue bezpośrednio do typu Type, a w postaci operatora trójskładnikowego najpierw prowadzi do wspólnego typu valTrue i valFalse (mimo że valFalse nie jest oceniony), a następnie wynik prowadzi do typu Type. Reguły rzutowania nie są całkowicie trywialne, jeśli wyrażenie obejmuje typy pierwotne i opakowania na nie (Integer, Double itp.). Wszystkie reguły są szczegółowo opisane w JLS 15.25. Spójrzmy na kilka przykładów. Number n = flag ? new Integer(1) : new Double(2.0); Co stanie się z n, jeśli flaga zostanie ustawiona? Obiekt Double o wartości 1,0. Kompilator uważa nasze niezdarne próby stworzenia obiektu za zabawne. Ponieważ drugi i trzeci argument opakowują różne typy pierwotne, kompilator rozpakowuje je i daje w wyniku bardziej precyzyjny typ (w tym przypadku double). A po wykonaniu operatora trójskładnikowego dla przypisania, boksowanie jest wykonywane ponownie. Zasadniczo kod jest równoważny temu: Number n; if( flag ) n = Double.valueOf((double) ( new Integer(1).intValue() )); else n = Double.valueOf(new Double(2.0).doubleValue()); z punktu widzenia kompilatora kod nie zawiera żadnych problemów i kompiluje się doskonale. Ale FindBugs ostrzega:
BX_UNBOXED_AND_COERCED_FOR_TERNARY_OPERATOR: Wartość pierwotna jest rozpakowywana i wymuszana dla operatora trójskładnikowego w TestTernary.main(String[]) Opakowana wartość pierwotna jest rozpakowywana i konwertowana na inny typ pierwotny w ramach oceny warunkowego operatora trójskładnikowego (operator b? e1: e2 ). Semantyka języka Java nakazuje, aby jeśli e1 i e2 są opakowanymi wartościami liczbowymi, wartości zostały rozpakowane i skonwertowane/wymuszone do ich wspólnego typu (np. jeśli e1 jest typu Integer, a e2 jest typu Float, wówczas e1 jest rozpakowywane, przekonwertowany na wartość zmiennoprzecinkową i opakowany w ramkę.Patrz JLS, sekcja 15.25. Oczywiście FindBugs ostrzega również, że Integer.valueOf(1) jest bardziej wydajna niż nowa Integer(1), ale każdy już o tym wie.
Lub ten przykład: Integer n = flag ? 1 : null; Autor chce umieścić wartość null w n, jeśli flaga nie jest ustawiona. Czy myślisz, że to zadziała? Tak. Ale skomplikujmy sprawę: Integer n = flag1 ? 1 : flag2 ? 2 : null; wydawałoby się, że nie ma dużej różnicy. Jednakże teraz, jeśli obie flagi są jasne, ta linia zgłasza wyjątek NullPointerException. Opcje prawego operatora trójskładnikowego to int i null, więc typem wyniku jest Integer. Opcje po lewej stronie to int i Integer, więc zgodnie z regułami Java wynikiem jest int. Aby to zrobić, musisz wykonać unboxing, wywołując intValue, który zgłasza wyjątek. Kod jest równoważny temu: Integer n; if( flag1 ) n = Integer.valueOf(1); else { if( flag2 ) n = Integer.valueOf(Integer.valueOf(2).intValue()); else n = Integer.valueOf(((Integer)null).intValue()); } Tutaj FindBugs generuje dwa komunikaty, które wystarczą, aby podejrzewać błąd:
BX_UNBOXING_IMMEDIATELY_REBOXED: Wartość opakowana jest rozpakowywana, a następnie natychmiast ponownie umieszczana w TestTernary.main(String[]) NP_NULL_ON_SOME_PATH: Możliwe wyłuskanie wskaźnika zerowego do wartości null w TestTernary.main(String[]) Istnieje gałąź instrukcji, która po wykonaniu gwarantuje, że Wartość null zostanie wyłuskana, co spowoduje wygenerowanie wyjątku NullPointerException podczas wykonywania kodu.
Cóż, ostatni przykład na ten temat: double[] vals = new double[] {1.0, 2.0, 3.0}; double getVal(int idx) { return (idx < 0 || idx >= vals.length) ? null : vals[idx]; } nie jest zaskakujące, że ten kod nie działa: w jaki sposób funkcja zwracająca typ pierwotny może zwrócić wartość null? Co ciekawe, kompiluje się bez problemów. Cóż, już rozumiesz, dlaczego się kompiluje.

Format daty

Do formatowania dat i godzin w Javie zaleca się użycie klas implementujących interfejs DateFormat. Na przykład wygląda to tak: public String getDate() { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); } Często klasa będzie używać tego samego formatu w kółko. Wiele osób wpadnie na pomysł optymalizacji: po co tworzyć obiekt formatu za każdym razem, gdy można skorzystać ze zwykłej instancji? private static final DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public String getDate() { return format.format(new Date()); } To takie piękne i fajne, ale niestety nie działa. Dokładniej działa, ale czasami się psuje. Faktem jest, że dokumentacja DateFormat mówi:
Formaty daty nie są zsynchronizowane. Zaleca się utworzenie oddzielnych instancji formatu dla każdego wątku. Jeśli wiele wątków jednocześnie uzyskuje dostęp do formatu, należy go zsynchronizować zewnętrznie.
I to prawda, jeśli spojrzysz na wewnętrzną implementację SimpleDateFormat. Podczas wykonywania metody format() obiekt zapisuje pola klasy, więc jednoczesne użycie SimpleDateFormat z dwóch wątków z pewnym prawdopodobieństwem doprowadzi do błędnego wyniku. Oto co pisze na ten temat FindBugs:
STCAL_INVOKE_ON_STATIC_DATE_FORMAT_INSTANCE: Wywołanie metody statycznej java.text.DateFormat w TestDate.getDate() Jak stwierdza JavaDoc, DateFormats są z natury niebezpieczne w przypadku użycia wielowątkowego. Detektor znalazł wywołanie instancji DateFormat, która została uzyskana za pomocą pola statycznego. To wygląda podejrzanie. Więcej informacji na ten temat można znaleźć w artykułach Sun Bug nr 6231579 i Sun Bug nr 6178997.

Pułapki BigDecimal

Dowiedziawszy się, że klasa BigDecimal pozwala na przechowywanie liczb ułamkowych o dowolnej precyzji i widząc, że ma konstruktor dla double, niektórzy uznają, że wszystko jest jasne i można to zrobić w ten sposób: System.out.println(new BigDecimal( 1.1)); Nikt tak naprawdę nie zabrania tego robić, ale wynik może wydawać się nieoczekiwany: 1,10000000000000088817841970012523233890533447265625. Dzieje się tak, ponieważ prymitywny podwójny jest przechowywany w formacie IEEE754, w którym nie da się idealnie dokładnie przedstawić 1,1 (w systemie liczb binarnych uzyskuje się nieskończony ułamek okresowy). Dlatego przechowywana jest tam wartość najbliższa 1,1. Wręcz przeciwnie, konstruktor BigDecimal(double) działa dokładnie: doskonale konwertuje daną liczbę w IEEE754 na postać dziesiętną (końcowy ułamek binarny jest zawsze przedstawiany jako końcowy ułamek dziesiętny). Jeśli chcesz przedstawić dokładnie 1,1 jako BigDecimal, możesz napisać nowy BigDecimal("1.1") lub BigDecimal.valueOf(1.1). Jeśli nie wyświetlisz numeru od razu, ale wykonasz na nim pewne operacje, możesz nie zrozumieć, skąd bierze się błąd. FindBugs wyświetla ostrzeżenie DMI_BIGDECIMAL_CONSTRUCTED_FROM_DOUBLE, które zawiera tę samą poradę. Oto kolejna rzecz: BigDecimal d1 = new BigDecimal("1.1"); BigDecimal d2 = new BigDecimal("1.10"); System.out.println(d1.equals(d2)); w rzeczywistości d1 i d2 reprezentują tę samą liczbę, ale równa się zwraca wartość false, ponieważ porównuje nie tylko wartość liczb, ale także bieżącą kolejność (liczbę miejsc po przecinku). Jest to napisane w dokumentacji, ale niewiele osób przeczyta dokumentację tak znanej metody jak równa. Taki problem może nie pojawić się natychmiast. Sam FindBugs niestety o tym nie ostrzega, ale istnieje do tego popularne rozszerzenie - fb-contrib, które uwzględnia ten błąd:
Funkcja MDM_BIGDECIMAL_EQUALS wywoływana jest metodą równa() w celu porównania dwóch liczb java.math.BigDecimal. Zwykle jest to błąd, ponieważ dwa obiekty BigDecimal są równe tylko wtedy, gdy są równe zarówno pod względem wartości, jak i skali, zatem 2,0 nie jest równe 2,00. Aby porównać obiekty BigDecimal pod kątem równości matematycznej, użyj zamiast tego funkcji CompareTo().

Podziały wierszy i printf

Często programiści, którzy przechodzą na Javę po C, chętnie odkrywają PrintStream.printf (a także PrintWriter.printf itp.). No super, wiem, że tak jak w C, nie trzeba się uczyć niczego nowego. Rzeczywiście istnieją różnice. Jednym z nich są tłumaczenia liniowe. Język C dzieli się na strumienie tekstowe i binarne. Wyprowadzenie znaku „\n” do strumienia tekstowego w jakikolwiek sposób zostanie automatycznie przekonwertowane na zależny od systemu znak nowej linii („\r\n” w systemie Windows). W Javie nie ma takiego rozdzielenia: do strumienia wyjściowego należy przekazać poprawną sekwencję znaków. Odbywa się to automatycznie, na przykład metodami z rodziny PrintStream.println. Ale podczas korzystania z printf przekazanie „\n” w ciągu formatującym to po prostu „\n”, a nie zależny od systemu znak nowej linii. Na przykład napiszmy następujący kod: System.out.printf("%s\n", "str#1"); System.out.println("str#2"); Po przekierowaniu wyniku do pliku zobaczymy: FindBugs pomaga lepiej nauczyć się języka Java - 1 W ten sposób można uzyskać dziwną kombinację podziałów wierszy w jednym wątku, co wygląda niechlujnie i może zwalić z nóg niektórym parserom. Błąd może pozostać niezauważony przez długi czas, szczególnie jeśli pracujesz głównie na systemach Unix. Aby wstawić prawidłowy znak nowej linii za pomocą printf, używany jest specjalny znak formatujący „%n”. Oto co pisze na ten temat FindBugs:
VA_FORMAT_STRING_USES_NEWLINE: Ciąg formatujący powinien używać %n zamiast \n w TestNewline.main(String[]) Ten ciąg formatujący zawiera znak nowego wiersza (\n). W ciągach formatujących ogólnie lepiej jest używać %n, co spowoduje utworzenie separatora linii specyficznego dla platformy.
Być może dla niektórych czytelników wszystko to było znane od dawna. Ale jestem prawie pewien, że czeka ich ciekawe ostrzeżenie ze strony analizatora statycznego, które ujawni im nowe cechy używanego języka programowania.
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION