Szukaj…


Wprowadzenie

Kilka błędów językowych w języku Java może spowodować, że program wygeneruje niepoprawne wyniki pomimo prawidłowej kompilacji. Głównym celem tego tematu jest lista typowych pułapek wraz z ich przyczynami oraz zaproponowanie właściwego sposobu uniknięcia takich problemów.

Uwagi

Ten temat dotyczy określonych aspektów składni języka Java, które są podatne na błędy lub których nie należy używać w określony sposób.

Pitfall - ignorowanie widoczności metody

Nawet doświadczeni programiści Java uważają, że Java ma tylko trzy modyfikatory ochrony. Język ma właściwie cztery! Prywatny poziom widoczności pakietu (inaczej domyślny) jest często zapominany.

Powinieneś zwrócić uwagę na to, jakie metody upubliczniasz. Metody publiczne w aplikacji to widoczny interfejs API aplikacji. Powinno to być tak małe i kompaktowe, jak to możliwe, szczególnie jeśli piszesz bibliotekę wielokrotnego użytku (zobacz także zasadę SOLID ). Ważne jest, aby w podobny sposób uwzględnić widoczność wszystkich metod i w stosownych przypadkach korzystać z chronionego lub pakietowego dostępu prywatnego.

Kiedy deklarujesz metody, które powinny być prywatne jako publiczne, ujawniasz wewnętrzne szczegóły implementacji klasy.

Następstwem tego jest to, że tylko testy jednostkowe metody publiczne klasy - w rzeczywistości można przetestować tylko metody publiczne. Złą praktyką jest zwiększanie widoczności metod prywatnych, aby móc przeprowadzać testy jednostkowe tych metod. Testowanie publicznych metod wywołujących metody o bardziej ograniczonej widoczności powinno wystarczyć do przetestowania całego interfejsu API. Nigdy nie należy rozszerzać interfejsu API o więcej metod publicznych, aby umożliwić testowanie jednostkowe.

Pitfall - Brak „przerwy” w przypadku „przełącznika”

Te problemy z Javą mogą być bardzo krępujące i czasami pozostają nieodkryte, dopóki nie zostaną uruchomione w środowisku produkcyjnym. Zachowanie przewrotne w instrukcjach przełączania jest często przydatne; jednak pominięcie słowa kluczowego „break”, gdy takie zachowanie nie jest pożądane, może prowadzić do katastrofalnych rezultatów. Jeśli zapomniałeś wstawić „break” w „case 0” w poniższym przykładzie kodu, program napisze „Zero”, a następnie „One”, ponieważ przepływ sterowania wewnątrz będzie przechodził przez całą instrukcję „switch”, aż dochodzi do „przerwy”. Na przykład:

public static void switchCasePrimer() {
        int caseIndex = 0;
        switch (caseIndex) {
            case 0:
                System.out.println("Zero");
            case 1:
                System.out.println("One");
                break;
            case 2:
                System.out.println("Two");
                break;
            default:
                System.out.println("Default");
        }
}

W większości przypadków czystszym rozwiązaniem byłoby użycie interfejsów i przeniesienie kodu o określonym działaniu do osobnych implementacji ( kompozycja zamiast dziedziczenia )

Jeśli nie da się uniknąć instrukcji zamiany, zaleca się udokumentowanie „oczekiwanych” spadków, jeśli wystąpią. W ten sposób pokazujesz innym programistom, że wiesz o brakującej przerwie i że jest to oczekiwane zachowanie.

switch(caseIndex) {
    [...]
    case 2:
        System.out.println("Two");
        // fallthrough
    default:
        System.out.println("Default");

Pitfall - Niewłaściwie umieszczone średniki i brakujące nawiasy klamrowe

Jest to błąd, który powoduje prawdziwe zamieszanie dla początkujących w Javie, przynajmniej za pierwszym razem, gdy to robią. Zamiast pisać to:

if (feeling == HAPPY)
    System.out.println("Smile");
else
    System.out.println("Frown");

przypadkowo piszą to:

if (feeling == HAPPY);
    System.out.println("Smile");
else
    System.out.println("Frown");

i są zdziwieni, gdy kompilator Java mówi im, że else jest źle umieszczone. Kompilator Java interpretuje powyższe w następujący sposób:

if (feeling == HAPPY)
    /*empty statement*/ ;
System.out.println("Smile");   // This is unconditional
else                           // This is misplaced.  A statement cannot
                               // start with 'else'
System.out.println("Frown");

W innych przypadkach nie wystąpią błędy kompilacji, ale kod nie zrobi tego, co zamierza programista. Na przykład:

for (int i = 0; i < 5; i++);
    System.out.println("Hello");

drukuje „Hello” tylko raz. Ponownie fałszywy średnik oznacza, że treść pętli for jest pustą instrukcją. Oznacza to, że wywołanie println które następuje, jest bezwarunkowe.

Kolejna odmiana:

for (int i = 0; i < 5; i++);
    System.out.println("The number is " + i);

To spowoduje błąd „Nie można znaleźć symbolu” dla i . Obecność fałszywego średnika oznacza, że wywołanie println próbuje użyć i poza swoim zakresem.

W tych przykładach istnieje proste rozwiązanie: wystarczy usunąć fałszywy średnik. Jednak z tych przykładów można wyciągnąć głębsze wnioski:

  1. Średnik w Javie to nie „szum składniowy”. Obecność lub brak średnika może zmienić znaczenie twojego programu. Nie dodawaj ich tylko na końcu każdej linii.

  2. Nie ufaj wcięciom kodu. W języku Java dodatkowe spacje na początku wiersza są ignorowane przez kompilator.

  3. Użyj automatycznego wgłębnika. Wszystkie IDE i wiele prostych edytorów tekstowych rozumie, jak poprawnie wciąć kod Java.

  4. To najważniejsza lekcja. Postępuj zgodnie z najnowszymi wytycznymi stylu Java i umieść nawiasy klamrowe wokół instrukcji „then” i „else” oraz instrukcji body pętli. Otwarty nawias klamrowy ( { ) nie powinien znajdować się w nowej linii.

Jeśli programista if do reguł stylu, wówczas przykład if ze źle umieszczonymi średnikami wyglądałby tak:

if (feeling == HAPPY); {
    System.out.println("Smile");
} else {
    System.out.println("Frown");
}

To wygląda dziwnie dla doświadczonego oka. Jeśli auto-wcięcie tego kodu prawdopodobnie wyglądałoby to tak:

if (feeling == HAPPY); {
                           System.out.println("Smile");
                       } else {
                           System.out.println("Frown");
                       }

co powinno wyróżniać się jako złe nawet dla początkującego.

Pitfall - Pozostawienie nawiasów klamrowych: problemy z „zwisającymi jeśli” i „zwisającymi innymi”

Najnowsza wersja przewodnika po stylu Java firmy Oracle nakazuje, aby instrukcje „then” i „else” w instrukcji if były zawsze ujęte w „nawiasy klamrowe” lub „nawiasy klamrowe”. Podobne reguły dotyczą treści różnych instrukcji pętli.

if (a) {           // <- open brace
    doSomething();
    doSomeMore();
}                  // <- close brace

W rzeczywistości nie jest to wymagane przez składnię języka Java. Rzeczywiście, jeśli część „następnie” instrukcji if jest pojedynczą instrukcją, legalne jest pominięcie nawiasów klamrowych

if (a)
    doSomething();

lub nawet

if (a) doSomething();

Istnieją jednak zagrożenia związane z ignorowaniem reguł stylu Java i pomijaniem nawiasów klamrowych. W szczególności znacznie zwiększasz ryzyko, że kod z błędnym wcięciem zostanie źle odczytany.

Problem „zwisający, jeśli”:

Rozważ przykładowy kod z góry, przepisany bez nawiasów klamrowych.

if (a)
   doSomething();
   doSomeMore();

Kod ten zdaje się mówić, że rozmowy do doSomething i doSomeMore będą występować zarówno wtedy i tylko wtedy, gdy jest a true . W rzeczywistości kod jest niepoprawnie wcięty. Specyfikacja języka Java, że doSomeMore() jest osobną instrukcją po instrukcji if . Prawidłowe wcięcie jest następujące:

if (a)
   doSomething();
doSomeMore();

Problem „wisi inaczej”

Drugi problem pojawia się, gdy dodamy else miks. Rozważ następujący przykład z brakującymi nawiasami klamrowymi.

if (a)
   if (b)
      doX();
   else if (c)
      doY(); 
else
   doZ();

Powyższy kod wydaje się doZ że doZ zostanie wywołane, gdy a jest false . W rzeczywistości wcięcie jest znowu niepoprawne. Prawidłowe wcięcie kodu to:

if (a)
   if (b)
      doX();
   else if (c)
      doY(); 
   else
      doZ();

Jeśli kod został napisany zgodnie z regułami stylu Java, wyglądałby tak:

if (a) {
   if (b) {
      doX();
   } else if (c) {
      doY(); 
   } else {
      doZ();
   }
}

Aby zilustrować, dlaczego tak jest lepiej, załóżmy, że przypadkowo źle wpisałeś kod. Możesz skończyć z czymś takim:

if (a) {                         if (a) {
   if (b) {                          if (b) {
      doX();                            doX();
   } else if (c) {                   } else if (c) {
      doY();                            doY();
} else {                         } else {
   doZ();                            doZ();
}                                    }
}                                }

Ale w obu przypadkach źle wcięty kod „wygląda źle” dla oka doświadczonego programisty Java.

Pitfall - Przeciążenie zamiast zastąpienia

Rozważ następujący przykład:

public final class Person {
    private final String firstName;
    private final String lastName;
   
    public Person(String firstName, String lastName) {
        this.firstName = (firstName == null) ? "" : firstName;
        this.lastName = (lastName == null) ? "" : lastName;
    }

    public boolean equals(String other) {
        if (!(other instanceof Person)) {
            return false;
        }
        Person p = (Person) other;
        return firstName.equals(p.firstName) &&
                lastName.equals(p.lastName);
    }

    public int hashcode() {
        return firstName.hashCode() + 31 * lastName.hashCode();
    }
}

Ten kod nie będzie działał zgodnie z oczekiwaniami. Problem polega na tym, że metody equals i hashcode dla Person nie zastępują standardowych metod zdefiniowanych przez Object .

  • Metoda equals ma niewłaściwy podpis. Powinien być zadeklarowany jako equals(Object) nie equals(String) .
  • Metoda hashcode ma niepoprawną nazwę. Powinien to być hashCode() (zwróć uwagę na wielką hashCode() C ).

Błędy te oznaczają, że zadeklarowaliśmy przypadkowe przeciążenia i nie zostaną one użyte, jeśli Person zostanie użyta w kontekście polimorficznym.

Istnieje jednak prosty sposób, aby sobie z tym poradzić (od wersji Java 5). Adnotacji @Override należy @Override zawsze, gdy zamierzasz zastąpić metodę:

Java SE 5
public final class Person {
    ...

    @Override
    public boolean equals(String other) {
        ....
    }

    @Override
    public hashcode() {
        ....
    }
}

Gdy dodamy @Override adnotacji do deklaracji metody, kompilator będzie sprawdzić, że metoda działa override (lub wdrożenie) metody zadeklarowane w nadrzędnej lub interfejs. W powyższym przykładzie kompilator zgłosi nam dwa błędy kompilacji, które powinny wystarczyć, aby ostrzec nas o błędzie.

Pitfall - literały ósemkowe

Rozważ następujący fragment kodu:

// Print the sum of the numbers 1 to 10
int count = 0;
for (int i = 1; i < 010; i++) {    // Mistake here ....
    count = count + i;
}
System.out.println("The sum of 1 to 10 is " + count);

Początkujący Java może być zaskoczony, gdy powyższy program wypisze złą odpowiedź. W rzeczywistości drukuje sumę liczb od 1 do 8.

Powodem jest to, że literał całkowity rozpoczynający się od cyfry zero („0”) jest interpretowany przez kompilator Java jako literał ósemkowy, a nie dziesiętny, jak można się spodziewać. Zatem 010 jest liczbą ósemkową 10, która jest liczbą dziesiętną 8.

Pitfall - Deklarowanie klas o takich samych nazwach jak klasy standardowe

Czasami programiści, którzy nie znają języka Java, popełniają błąd, definiując klasę o nazwie takiej samej jak klasa powszechnie używana. Na przykład:

package com.example;

/**
 * My string utilities
 */
public class String {
    ....
}

Następnie zastanawiają się, dlaczego dostają nieoczekiwane błędy. Na przykład:

package com.example;

public class Test {
    public static void main(String[] args) {
        System.out.println("Hello world!");
    }
}

Jeśli skompilujesz, a następnie spróbujesz uruchomić powyższe klasy, pojawi się błąd:

$ javac com/example/*.java
$ java com.example.Test
Error: Main method not found in class test.Test, please define the main method as:
   public static void main(String[] args)
or a JavaFX application class must extend javafx.application.Application

Ktoś patrząc na kod klasy Test widziałby deklarację main i patrzył na jej podpis i zastanawiał się, na co narzeka komenda java . Ale w rzeczywistości polecenie java mówi prawdę.

Gdy deklarujemy wersję String w tym samym pakiecie co Test , ta wersja ma pierwszeństwo przed automatycznym importem java.lang.String . Tak więc podpis metody Test.main jest w rzeczywistości

void main(com.example.String[] args) 

zamiast

void main(java.lang.String[] args)

a polecenie java nie rozpozna tego jako metody punktu wejścia.

Lekcja: nie definiuj klas o takich samych nazwach, jak klasy istniejące w java.lang lub innych powszechnie używanych klas w bibliotece Java SE. Jeśli to zrobisz, otworzysz się na wszelkiego rodzaju niejasne błędy.

Pitfall - użycie „==” do przetestowania wartości logicznej

Czasami nowy programista Java napisze następujący kod:

public void check(boolean ok) {
    if (ok == true) {           // Note 'ok == true'
        System.out.println("It is OK");
    }
}

Doświadczony programista zauważyłby to jako niezdarne i chciałby przepisać to jako:

public void check(boolean ok) {
    if (ok) {
       System.out.println("It is OK");
    }
}

Jest jednak więcej błędu w przypadku ok == true niż zwykła niezdarność. Rozważ tę odmianę:

public void check(boolean ok) {
    if (ok = true) {           // Oooops!
        System.out.println("It is OK");
    }
}

Tutaj programista błędnie wpisał == as = ... a teraz kod ma subtelny błąd. Wyrażenie x = true bezwarunkowo przypisuje true x a następnie zwraca wartość true . Innymi słowy, metoda check wyświetli teraz komunikat „Jest OK” bez względu na parametr.

Lekcja polega na tym, aby wyjść z nawyku używania == false i == true . Oprócz tego, że są pełne, sprawiają, że kodowanie jest bardziej podatne na błędy.


Uwaga: Możliwą alternatywą dla ok == true która pozwala uniknąć pułapki, jest użycie warunków Yoda ; tzn. umieść literał po lewej stronie operatora relacyjnego, jak w true == ok . To działa, ale większość programistów prawdopodobnie zgodziłaby się, że warunki Yody wyglądają dziwnie. Z pewnością ok (lub !ok ) jest bardziej zwięzłe i bardziej naturalne.

Pitfall - importowanie symboli wieloznacznych może spowodować, że Twój kod będzie kruchy

Rozważ następujący częściowy przykład:

import com.example.somelib.*;
import com.acme.otherlib.*;

public class Test {
    private Context x = new Context();   // from com.example.somelib
    ...
}

Załóżmy, że kiedy pierwszy raz opracowałeś kod przeciwko wersji 1.0 somelib i wersji 1.0 otherlib . Następnie w pewnym momencie musisz zaktualizować swoje zależności do nowszych wersji i zdecydować się na użycie otherlib wersji 2.0. Załóżmy również, że jedną ze zmian, które wprowadzili w otherlib między 1.0 a 2.0, było dodanie klasy Context .

Teraz podczas ponownej kompilacji Test pojawi się błąd kompilacji informujący, że Context jest niejednoznacznym importem.

Jeśli znasz bazę kodu, jest to prawdopodobnie niewielka niedogodność. Jeśli nie, to masz trochę pracy, aby rozwiązać ten problem, tutaj i potencjalnie gdzie indziej.

Problemem jest tutaj import symboli wieloznacznych. Z jednej strony użycie symboli wieloznacznych może skrócić twoje klasy o kilka linii. Z drugiej strony:

  • Kompatybilne w górę zmiany innych części bazy kodu, standardowych bibliotek Java lub bibliotek stron trzecich mogą prowadzić do błędów kompilacji.

  • Cierpi czytelność. O ile nie używasz IDE, ustalenie, który z importowanych symboli wieloznacznych przyciąga nazwaną klasę, może być trudne.

Lekcja polega na tym, że używanie kodu wieloznacznego w kodzie jest kiepskim pomysłem, który wymaga długiego życia. Konkretne importowanie (bez symboli wieloznacznych) nie wymaga dużego wysiłku, jeśli używasz IDE, a wysiłek jest opłacalny.

Pitfall: Używanie „asert” do sprawdzania poprawności argumentów lub danych wprowadzanych przez użytkownika

Pytanie, które od czasu do czasu na StackOverflow brzmi, czy należy użyć assert aby sprawdzić poprawność argumentów dostarczonych do metody, a nawet danych wejściowych dostarczonych przez użytkownika.

Prosta odpowiedź brzmi: nie jest właściwe.

Lepsze alternatywy obejmują:

  • Zgłaszanie wyjątku IllegalArgumentException przy użyciu niestandardowego kodu.
  • Korzystanie z metod Preconditions dostępnych w bibliotece Google Guava.
  • Korzystanie z Validate metod dostępnych w bibliotece Apache Commons Lang3.

Oto, co radzi specyfikacja języka Java (JLS 14.10 dla Java 8) :

Zazwyczaj sprawdzanie asercji jest włączane podczas opracowywania i testowania programu, a wyłączane podczas wdrażania, aby poprawić wydajność.

Ponieważ asercje mogą być wyłączone, programy nie mogą zakładać, że wyrażenia zawarte w asercjach zostaną ocenione. Zatem te wyrażenia logiczne powinny zasadniczo być wolne od skutków ubocznych. Ocena takiego wyrażenia logicznego nie powinna wpływać na żaden stan widoczny po zakończeniu oceny. Nie jest nielegalne, aby wyrażenie boolowskie zawarte w asercji miało efekt uboczny, ale jest ogólnie niewłaściwe, ponieważ może powodować, że zachowanie programu będzie się różnić w zależności od tego, czy asercje były włączone, czy wyłączone.

W świetle tego nie należy wykorzystywać twierdzeń do sprawdzania argumentów w metodach publicznych. Sprawdzanie argumentów jest zazwyczaj częścią kontraktu metody, a umowa ta musi zostać utrzymana, niezależnie od tego, czy asercje są włączone, czy wyłączone.

Drugi problem z używaniem asercji do sprawdzania argumentów polega na tym, że błędne argumenty powinny skutkować odpowiednim wyjątkiem w czasie wykonywania (takim jak IllegalArgumentException , ArrayIndexOutOfBoundsException lub NullPointerException ). Niepowodzenie potwierdzenia nie spowoduje wygenerowania odpowiedniego wyjątku. Ponownie stosowanie twierdzeń do sprawdzania argumentów metod publicznych nie jest nielegalne, ale ogólnie jest niewłaściwe. Zakłada się, że błąd AssertionError nigdy nie zostanie złapany, ale można to zrobić, dlatego reguły instrukcji try powinny traktować twierdzenia pojawiające się w bloku try podobnie jak bieżące traktowanie instrukcji throw.

Pułapka automatycznego rozpakowywania obiektów zerowych na prymitywy

public class Foobar {
    public static void main(String[] args) {

        // example: 
        Boolean ignore = null;
        if (ignore == false) {
            System.out.println("Do not ignore!");
        }
    }
}

Pułapka polega na tym, że null jest porównywane z false . Ponieważ porównujemy prymitywną wartość boolean z wartością Boolean , Java próbuje rozpakować Object Boolean na pierwotny odpowiednik, gotowy do porównania. Ponieważ jednak ta wartość jest równa null , NullPointerException jest NullPointerException .

Java nie jest w stanie porównać typów pierwotnych z wartościami null , co powoduje NullPointerException w czasie wykonywania. Rozważ pierwotny przypadek warunku false == null ; wygenerowałoby to błąd czasu kompilacji incomparable types: int and <null> .



Modified text is an extract of the original Stack Overflow Documentation
Licencjonowany na podstawie CC BY-SA 3.0
Nie związany z Stack Overflow