Praca w IT

Najczęściej kopiowany fragment kodu na Stack Overflow był wadliwy!

W ostatnim badaniu zatytułowanym Usage and Attribution of Stack Overflow Code Snppets in GitHub Project (pl. Wykorzystanie i przypisanie fragmentów kodu Stack Overflow w projektach GitHub), odpowiedź, którą napisałem niemalże dekadę temu, okazała się najczęściej kopiowanym fragmentem kodu. Jak na ironię jest on uszkodzony.

Andreas Lundblad. Software developer z pasją. Po ukończeniu doktoratu z informatyki dołączył do zespołu langtools w Oracle, gdzie kompilował w Javie i innych narzędziach związanych z językiem. W 2016 roku rozpoczął pracę w Palantir Technologies jako Forward Deployed Engineer. Obecnie zajmuje 10. miejsce na liście 100 najlepszych użytkowników Stack Overflow i został wybrany jako jeden z najlepszych programistów w Szwecji poniżej 30. roku życia. Prowadzi blog programming.guide.


Dawno, dawno temu…

W 2010 roku siedziałem w swoim biurze i robiłem to, czego nie powinienem robić: code golfingowałem i podbijałem reputację na Stack Overflow. Moją uwagę zwróciło następujące pytanie: Jak wydrukować liczbę bajtów w czytelnym dla zwykłego człowieka formacie? To znaczy, jak sformatować coś w stylu 123,456,789 bajtów na “123,5 MB”.

Stary dobry wygląd Stacka z 2010 roku. Można go uzyskać przez Wayback Machine

Łańcuch wynikowy powinien mieć wartość między 1 a 999,9 i poprzedzający go suffix powinien być odpowiedniej wielkości.

Pod zadanym pytaniem znalazła się wówczas jedna odpowiedź. Zawarty w niej kod oparty został na pętli. Pomysł był prosty: wypróbuj wszystkie wielkości od największej (EB= 1018 bajtów) do najmniejszej (B = 1 bajt) i użyj pierwszego, który jest mniejszy niż liczba bajtów. W pseudo-kodzie wygląda to tak:

Zwykle najwyżej punktowaną odpowiedź n Stack Overflow ciężko dogonić. W żargonie określamy to jako Problem Najszybszego Strzelca na Zachodzie (ang. The Fastest Gun in the West Problem). W tym przypadku istniejąca odpowiedź miała kilka wad, więc wciąż widziałem szansę na przebicie jej. W końcu kod oparty na pętli można znacznie uprościć.

To jest przecież algebra, znam więc odpowiedź!

I wtedy do mnie dotarło. kB, MB, GB… Suffixy to nic innego jak moc 1000 (lub 1024 w standardzie IEC), co znaczy, że powinno być możliwe obliczenie odpowiedniego suffixu za pomocą logarytmów zamiast pętli.

Na podstawie tego opublikowałem odpowiedź:

public static String humanReadableByteCount(long bytes, boolean si) {
    int unit = si ? 1000 : 1024;
    if (bytes < unit) return bytes + " B";
    int exp = (int) (Math.log(bytes) / Math.log(unit));
    String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
    return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
}

Fakt, kod nie jest zbyt czytelny, a log / pow prawdopodobnie czyni go mniej wydajnym niż inne rozwiązania, ale nie ma w nim żadnych pętli i prawie żadnych rozgałęzień, co moim zdaniem było całkiem fajne.

Kryjąca się za tym matematyka jest prosta. Liczba bajtów wyrażana jest w byteCount = 1000s, gdzie s oznacza skalę (w przypadku zapisu binarnego używana jest podstawa 1024). Rozwiązanie dla s daje s = log1000 (byteCount).

W interfejsie API nie ma łatwo dostępnego log1000, ale możemy wyrazić go logarytmem naturalnym w następujący sposób: s = log(byteCount) / log(1000). Jeśli dla przykładu mamy więcej niż jeden megabajt (ale nie pełnego gigabajta) i chcemy użyć MB jako wielkości, to sprowadzamy do podłogi s (oddanych do int).

W tym momencie, jeśli s = 1 skala to kilobajty, jeśli s = 2 skala to megabajty i tak dalej. Dzielimy byteCount przez 1000s i wklepujemy odpowiedni prefiks litery.

Po opublikowaniu tego mogłem tylko czekać i sprawdzać, czy społeczność doceni odpowiedź. Nie spodziewałem się, że będzie to najbardziej kopiowany fragment kodu na Stack Overtflow.

Studium na temat atrybucji

W 2018 roku doktorant Sebastian Baltes opublikował artykuł w czasopiśmie Emirical Software Enigneering pt. Usage and Attribution of Stack Overflow Code Snippets in GitHub Projects. I zasadniczo próbuje w nim odpowiedzieć na pytanie: Czy przestrzegana jest licencja Stack Overflow CC BY-SA 3.0? Czyli, w skrócie, czy można korzystać z kodu opublikowanego przez jednego z użytkowników?

Fragmenty kodu dostosowano do kodu ze zrzutu danych Stack Overflow i dopasowano je do kodu z publicznych repozytoriów GitHub. Cytując streszczenie:

Prezentujemy wyniki wielkoskalowego badania empirycznego analizującego wykorzystanie i przypisywanie fragmentów kodu Java z odpowiedzi SO w publicznych projektach GitHub (GH).

(Uwaga, spoiler: Nie. Większość osób nie uwzględnia prawidłowego przypisania)

W artykule znaleźć można następującą tabelę:

Odpowiedź na górze o numerze identyfikacyjnym 3758880 jest właśnie tą odpowiedzią, którą zamieściłem tam osiem lat wcześniej. W tym momencie miała już ponad setki tysięcy wyświetleń i ponad tysiąc pozytywnych opinii.

Szybkie wyszukiwanie na GitHub potwierdza, że rzeczywiście kod humanReadableByteCount występuje tysiące razy.

Możesz sprawdzić, czy masz go w lokalnym repozytorium, wpisując komendę:

$ git grep humanReadableByteCount

Bonusowa anegdotka: kiedy po raz pierwszy usłyszałem o tym badaniu?

Sebastian znalazł dopasowanie w repozytorium OpenJDK. Nie było przypisu, a licencja OpenJDK nie była kompatybilna z CC BY-SA 3.0. Sięgnął więc do dev listy mailingowej z zapytaniem, czy kod na Stack Overflow był skopiowany z OpenJDK, czy było to zrobione na odwrót.

Zabawne jest to, że kiedyś pracowałem w Oracle przy projekcie OpenJDK, więc mój były współpracownik i kolega odpisał w następujący sposób:

Cześć,

Dlaczego nie zapytasz autora tego postu (aioobe) bezpośrednio? Jest współpracownikiem OpenJDK i był zatrudniony przez Oracle w momencie pojawienia się tego kodu w repozytoriach źródłowych OpenJDK.

/Claes

Oracle nie lekceważy takich zgłoszeń. Wiem, że niektórzy ludzie z firmy musieli odetchnąć z ulgą, kiedy przeczytali tę odpowiedź i uznali to za drobny triumf po tym „oskarżeniu”.

Po uzyskaniu odpowiedzi, Sebastian zwrócił się do mnie, abym to wyjaśnił, co też zrobiłem. Nie pracowałem w Oracle, kiedy zostało to zatwierdzone i nie włączyłem tej łatki. Piłka po Waszej stronie, Oracle. Krótko po wszystkim problem został naprawiony (https://bugs.openjdk.java.net/browse/JDK-8170860), a kod usunięty (http://hg.openjdk.java.net/jdk9/jdk9/hotspot/rev/b552b596203f).

Błąd

Założę się, że już zacząłeś się zastanawiać. Gdzie jest błąd we fragmencie tego kodu?

Powtórzę:

public static String humanReadableByteCount(long bytes, boolean si) {
    int unit = si ? 1000 : 1024;
    if (bytes < unit) return bytes + " B";
    int exp = (int) (Math.log(bytes) / Math.log(unit));
    String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
    return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
}

Po eksabajtach 1018 przychodzą zettabajty 1021. Czy to możliwe, że naprawdę duże dane wejściowe powodują, że indeks wykracza poza granice „kMGTPE” ciągu? Nie. Maksymalna wartość long wynosi 263 - 1 ≈ 9.2 × 1018, więc żadna wartość long nigdy nie przekroczy wartości EB.

Czy może to być pomieszanie SI i binarnych? Nie. Wczesna wersja odpowiedzi zawierała pomyłkę, ale dość szybko została ona naprawiona.

Czy w końcu exp może być 0, powodując awarię charAt(exp-1)? Pierwsze if-statement koryguję tę sprawę. Wartość exp zawsze będzie wynosić przynajmniej 1.

Czy może to być jakiś dziwny błąd zaokrąglenia na wyjściu? No właśnie.

Wiele dziewiątek

Rozwiązanie działa aż do osiągnięcia 1 MB. Gdy podano 999,999 bajtów jako dane wejściowe, w trybie SI wynikiem jest "1000.0 kB". Chociaż prawdą jest, że 999,999 jest bliższy 1000 x 10001 niż 999,9 x 1000 do 1. 1000 „znaczeń” jest poza zakresem, zgodnie ze specyfikacją. Prawidłowy wynik wynosi"1.0 MB".

Jeśli ma to jakiekolwiek znaczenie, wszystkie 22 opublikowane odpowiedzi, w tym te wykorzystujące biblioteki Apache Commons i Android, miały ten błąd (lub jego odmianę) w momencie pisania.

Jak to naprawić? Przede wszystkim zauważmy, że wykładnik (exp) powinien zmienić się z „k” na „M”, gdy tylko liczba bajtów będzie bliższa 1 × 1,0002 (1 MB) niż 999.9 × 10001 (999,9 k). Dzieje się tak przy 999,950. Podobnie powinniśmy przejść z „M” na „G”, gdy miniemy 999,950 i tak dalej.

Aby to osiągnąć, obliczamy ten próg i wpisujemy, że exp jeśli bytes jest większy:

if (bytes >= Math.pow(unit, exp) * (unit - 0.05))
    exp++;

Dzięki tej zmianie kod działa dobrze aż do momentu, gdy liczba bajtów zbliży się do 1 EB.

Jeszcze więcej dziewiątek

Biorąc pod uwagę otrzymane 999,949,999,999,999,999, ukazuje się nam wynik 1000.0 PB, kiedy poprawną odpowiedzią jest 999.9 PB . Pod względem matematycznym kod jest poprawny, więc co właściwie się stało?

W tym momencie natrafiamy na ograniczenia dokładności double.

Arytmetyka zmiennoprzecinkowa 101

Ze względu na IEEE 754, wartości zmiennoprzecinkowe bliskie zeru są bardzo gęste, a duże wartości bardzo rzadkie. W rzeczywistości połowa wszystkich wartości znajduje się między -1 a 1, a gdy mówimy o dużych podwójnych, wartość tak duża jak Long.MAX_VALUE nic nie znaczy. Dosłownie.

double l1 = Double.MAX_VALUE;
double l2 = l1 - Long.MAX_VALUE;
System.err.println(l1 == l2);  // prints true

Więcej o tym w artykule Bits of a Floating Point Value.

Istnieją dwa problematyczne obliczenia:

  • Podział argumentuString.format
  • Próg uderzenia exp.

Moglibyśmy przejść na BigDecimal, ale gdzie w tym zabawa? Poza tym i tak robi się bałagan, ponieważ BigDecimal w standardowym interfejsie API nie ma funkcji dziennika.

Skalowanie wartości pośrednich

W przypadku pierwszego problemu możemy zmniejszyć wartość bytes do precyzyjniejszego zakresu i odpowiednio dostosować exp. Końcowy wynik jest zaokrąglany, więc nie ma znaczenia, że wyrzucamy najmniej znaczące cyfry.

if (exp > 4) {
    bytes /= unit;
    exp--;
}

Dostosowywanie najmniej znaczących bitów

W przypadku drugiej kwestii powinniśmy zadbać o najmniej znaczące bity (999,949,99…9 i 999,950,00… 0 powinny kończyć się różnymi wykładnikami), zatem wymaga to innego rozwiązania.

Po pierwsze, zauważmy, że istnieje 12 różnych możliwości wartości progu (6 dla każdego trybu) i tylko jedna z nich kończy się błędem. Błędny wynik może być jednoznacznie zidentyfikowany przez fakt, że kończy się na D0016. W takim przypadku po prostu dostosowujemy go do prawidłowej wartości.

long th = (long) (Math.pow(unit, exp) * (unit - 0.05));
if (exp < 6 && bytes >= th - ((th & 0xFFF) == 0xD00 ? 52 : 0))
    exp++;

Ponieważ polegamy na określonych wzorach bitowych w wyniku zmiennoprzecinkowych, wklepujemy strictfp, żeby upewnić się, że działa niezależnie od sprzętu, na którym działa kod.

Ujemne dane wejściowe

Nie jest jasne, w jakich okolicznościach ujemna liczba bajtów może być istotna, ale ponieważ Java nie ma niepodpisanego long, lepiej sobie z tym poradzić. W tej chwili dane wejściowe takie jak -10 000 dają wynik -10000 B.

Używamy zatem absBytes:

long absBytes = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);

Skomplikowane wyrażenie wynika z faktu, że -Long.MIN_VALUE == Long.MIN_VALUE. Teraz wykonujemy wszystkie obliczenia związane z używaniem exp absBytes zamiast bytes.

Wersja ostateczna

Oto ostateczna wersja kodu, zgolfowana w duchu ostatecznej wersji:

// From: https://programming.guide/worlds-most-copied-so-snippet.html
public static strictfp String humanReadableByteCount(long bytes, boolean si) {
    int unit = si ? 1000 : 1024;
    long absBytes = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);
    if (absBytes < unit) return bytes + " B";
    int exp = (int) (Math.log(absBytes) / Math.log(unit));
    long th = (long) (Math.pow(unit, exp) * (unit - 0.05));
    if (exp < 6 && absBytes >= th - ((th & 0xfff) == 0xd00 ? 52 : 0)) exp++;
    String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp - 1) + (si ? "" : "i");
    if (exp > 4) {
        bytes /= unit;
        exp -= 1;
    }
    return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
}

Zauważ, że od samego początku chodziło o uniknięcie pętli i nadmiernego rozgałęziania. Kod jest jeszcze mniej czytelny niż wersja pierwotna. Osobiście nie skopiowałbym tego fragmentu kodu do kodu produkcyjnego.

Aby uzyskać zaktualizowany kod o jakości produkcyjnej, zobacz osobny artykuł: Formatting byte size to human readable format.

Kluczowe wnioski

  • Fragmenty kodu Stack Overflow, mimo tysiąca pozytywnych opinii, mogą okazać się błędne,
  • Przetestuj wszystkie przypadki brzegowe, szczególnie dla kodu skopiowanego ze Stack Overflow,
  • Arytmetyka zmiennoprzecinkowa jest trudna,
  • Podczas kopiowania kodu należy uwzględnić prawidłowe przypisanie. Ktoś później może zechcieć to zweryfikować.

Artykuł opublikowaliśmy za zgodą autora. Autorem tłumaczenia jest Adam Zamczała. Zdjęcie główne tekstu pochodzi z unsplash.com.

Wraz z Tomaszem Gańskim jestem współtwórcą justjoin.it - największego job boardu dla polskiej branży IT. Portal daje tym samym największy wybór spośród branżowych stron na polskim rynku. Rozwijamy go organicznie, serdecznie zapraszam tam również i Ciebie :)

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/wadliwy-stackoverflow/" order_type="social" width="100%" count_of_comments="8" ]