lambdaniel

Daniel Janus o języku, typografii, książkach, programowaniu w Clojure i nie tylko

Posted in programowanie

Praktyczne użycie monady state

Wreszcie rozumiem monady!

Pierwszy raz zetknąłem się z nimi dobrych osiem lat temu, przy okazji nauki Haskella; wtedy jednak nie starczyło mi cierpliwości, aby zaznajomić się z podstawami teoretycznymi. Sprawy nie ułatwiał fakt, że trudno o naprawdę przystępne i zrozumiałe wprowadzenie do tego tematu: wygląda na to, że każdy adept Haskella, zrozumiawszy monady, pisze na ten temat własny tutorial. Ja się powstrzymam od tego naturalnego odruchu i po prostu odeślę do niesamowicie szczegółowego, ośmioczęściowego cyklu artykułów autorstwa Mike'a Vaniera, który — wreszcie! — sprawił, że coś mi “zaskoczyło” w umyśle. Zamiast tego w tym wpisie wynotuję najważniejsze spostrzeżenia, jakie zapamiętałem, a potem pokażę dwie wersje pewnego kodu operującego na sekwencjach bitów: niemonadyczną i napisaną z użyciem monady state.

Mike zakłada znajomość Haskella, jak pisze we wstępie, na poziomie typów polimorficznych i klas typów; w praktyce jednak myślę, że do zrozumienia tekstu wystarczy pewne obycie z haskellową notacją (bardzo zresztą przypominającą zwykłą notację matematyczną), bo bardziej zaawansowane rzeczy są wyjaśniane w tekście na bieżąco. Bardzo polecam.

Oto więc moje notatki:

  • Kluczowe dla zrozumienia monad jest pojęcie funkcji monadycznych; znacznie łatwiej jest je sobie intuicyjnie wyobrazić niż monadyczne wartości, zwracane przez m-return. Mike podaje pewne intuicje także dla tych drugich, ale dla mnie ważne jest, żeby myśleć o monadach w kategoriach (wzbogaconych) funkcji, a nie wartości przez nie zwracanych.
  • Łatwo wydefiniować przy użyciu prostego złożenia operacji m-bind i m-return funkcję składającą ze sobą dwie funkcje monadyczne. Przy użyciu tego operatora, który Mike nazywa <=<, prawa rządzące monadami wyrażają się w elegancki i prosty do zapamiętania sposób: jest to operator łączny oraz m-return jest jego lewą i prawą jedynką.
  • I bodaj najistotniejsza rzecz — nie trzeba rozumieć monad, żeby ich używać. Niby oczywiste i niby tak właśnie pisałem do tej pory kod haskellowy, radośnie używając do-notacji tak jakbym pisał kod imperatywny; ale okazuje się, że w ten sam sposób można z powodzeniem używać monady stanu i wyjaśnić, co ona robi, zupełnie w oderwaniu od całej reszty teorii monad. Wiedza na temat tego, co się dzieje “pod spodem”, jest konieczna tylko (i aż!) do zrozumienia, co poszczególne monady mają ze sobą wspólnego.

Reszta tego wpisu będzie poświęcona właśnie przykładowemu użyciu monady stanu w Clojure. Pisałem wcześniej, że nie widziałem dotąd kodu, który dałby się wyrazić przejrzyściej zapisany z użyciem monad niż bez nich — i właśnie na taki kod się natknąłem.

Wyobraźmy więc sobie, że mamy strumień bitów. Dla prostoty zdefiniujmy sobie przykładowy strumień, na którym będziemy testować nasze funkcje, jako clojurowy wektor zer i jedynek.

(def v [1 1 1 0 1 0 1 0 1 0])

Chcemy naddać naszemu strumieniowi pewne uporządkowanie: zdekodować zera i jedynki, które z niego przychodzą, według jakiegoś kodu o zmiennej długości. Najbardziej oczywistym, znanym każdemu kodem jest kod binarny: czytamy ze strumienia ileś bitów, traktujemy je jako binarną (bez znaku) reprezentację pewnej liczby i zwracamy tę liczbę. Łatwo napisać w Clojure funkcję, która to robi.

(defn read-binary [n bits]
  (loop [res 0 n n [fst & rst :as bs] bits]
    (if (zero? n)
      [res bs]
      (recur (+ res res fst) (dec n) rst))))

Spróbujmy wczytać z naszego strumienia czterobitową liczbę:

(read-binary 4 v)
;=> [14 (1 0 1 0 1 0)]

Czternaście. Ale zauważmy, że zdekodowana liczba nie jest jedyną zwracaną wartością! Żeby dało się z naszego strumienia odczytywać dalsze liczby, musimy zwrócić parę (wektor dwuelementowy): wartość odczytana plus reszta strumienia, z ktróej będziemy czytać dalej. Piszemy wszak funkcyjnie: nie zmieniamy naszych wartości w miejscu, tylko z jednych wartości produkujemy następne.

Innym rodzajem kodu jest kod unarny: czytamy po prostu ze strumienia jedynki, aż napotkamy pierwsze zero — wtedy przestajemy czytać i naszą wartością jest liczba przeczytanych bitów. W ten sposób dowolną liczbę dodatnią n da się zareprezentować na n bitach. Implementacja jest równie prosta:

(defn read-unary [bits]
  (loop [n 1 [fst & rst] bits]
    (if (zero? fst)
      [n rst]
      (recur (inc n) rst))))

Sprawdzamy:

(read-unary v)
;=> [4 (1 0 1 0 1 0)]

Trzy jedynki i zero, razem cztery skonsumowane bity, więc odczytaną liczbą jest 4. Działa.

Spróbujmy teraz skleić nasze funkcje, to znaczy odczytać ze strumienia dwie liczby: najpierw zakodowaną unarnie, a potem binarnie na sześciu bitach.

(let [[x v1] (read-unary v)
      [y v2] (read-binary 6 v1)]
  [x y])
;=> [4 42]

Wynik jest poprawny, ale kod nieelegancki: tak naprawdę nie interesują nas te wszystkie v, v1, v2, które przepychamy przez nasze funkcje; potrzebujemy tylko przeczytanych liczb, a poszczególne części strumienia są nam tylko po to, żeby mieć z czego czytać. Im więcej składanych ze sobą operacji, tym łatwiej się pomylić.

Tu wkracza monada stanu, która umożliwia ukrycie tych pośrednich wartości i bardziej eleganckie złożenie read-unary i read-binary. Monadycznie zapisalibyśmy to jakoś tak:

(domonad state-m
         [x read-unary
          y (partial read-binary 6)]
         [x y])
;=> #

Wygląda to bardzo podobnie. domonad state-m jest jak let. Tak jak chcieliśmy, nie przekazujemy naszego stanu (strumienia) explicite; zamiast tego przy x i y podajemy funkcje, które mają być zawołane na aktualnym stanie, żeby uzyskać żądaną wartość i nowy stan.

No dobrze, ale gdzie tu miejsce na nasz stan początkowy? Jak go przekazać? Proste: powyższa formuła, jak widać z mało czytelnego wyniku, zwróciła jakąś funkcję. Wystarczy ją teraz zawołać na naszym początkowym stanie, aby uzyskać wynik wraz ze stanem końcowym:

(*1 v)
;=> [[4 42] nil]

Jedyne, co wydaje się nadmiarowe w naszym złożeniu, to owo partial pojawiające się wyżej. Jest to efekt uboczny faktu, że Clojure w odróżnieniu od Haskella rozróżnia funkcje jedno- i więcejargumentowe (tak dokładniej to w Haskellu występują wyłącznie funkcje jednoargumentowe). Skoro w domonad state-m powinny na przemian pojawiać się symbole i funkcje jednoargumentowe biorące stan, to gdy nasza funkcja akceptuje coś jeszcze (tu: liczbę bitów), powinniśmy zrobić z niej funkcję jednoargumentową za pomocą partial. Alternatywnie, jeżeli chcemy trzymać się stylu monadycznego, możemy przepisać read-binary jako:

(defn read-binary [n]
  (fn [bits]
    (loop [res 0 n n [fst & rst :as bs] bits]
      (if (zero? n)
        [res bs]
        (recur (+ res res fst) (dec n) rst)))))

Teraz zamiast (partial read-binary 6) możemy napisać po prostu (read-binary 6).

Teraz, kiedy umiemy już składać operacje stanowe, zilustrujemy to przy pomocy jeszcze jednego kodu, a właściwie rodziny kodów nazywanych kodami Golomba. Po szczegółowy opis odsyłam do Wikipedii, natomiast dla potrzeb tego artykułu wystarczy nam wiedza, że te kody są parametryzowane jedną liczbą m. Liczba w kodzie Golomba jest podzielona na dwie części, z których pierwsza jest zakodowana unarnie, a druga — nie większa niż m — binarnie. W implementacji możemy więc skorzystać z naszych gotowych już funkcji read-unary i read-binary.

Bez dłuższych już komentarzy przedstawię dwie implementacje: eksplicytną i używającą monady stanu. Obie będą używać pomocniczej funkcji read-bit, czytającej po prostu jeden bit ze strumienia i używanej tak samo jak read-unary czy read-binary.

(defn read-bit [[bit & bits]]
  [bit bits])

Najpierw wersja niemonadyczna:

(defn read-golomb [m]
  (fn [bits]
    (let [k (clog2 m)
          r (- (bit-shift-left 1 k) m)
          [a bits1] (read-unary bits)
          [b bits2] ((read-binary (dec k)) bits1)
          [ir bits] (if (< b r)
                      [b bits]
                      (let [[nb bits] (read-bit bits)]
                        [(+ b b nb (- r)) bits]))]
      [(+ (* (dec a) m) ir) bits])))

A teraz używająca monad. Żeby nie przeplatać let i domonad, można po prostu zdefiniować funkcję zwracającą pewną stałą wartość i niezmieniony stan — nazwę ją m-const:

(defn m-const [x]
  (fn [state]
    [x state]))

Używając jej, możemy zaimplementować read-golomb tak:

(defn read-golomb-monadic [m]
  (domonad state-m
           [k (m-const (clog2 m))
            r (m-const (- (bit-shift-left 1 k) m))
            a read-unary
            b (read-binary (dec k))
            nb (if (< b r) (m-const nil) read-bit)]
           (+ (* (dec a) m)
              (if (< b r) b (+ b b nb (- r))))))

Czytelniej?

Posted on 2012-01-10

ClojureScript

Rich Hickey, autor Clojure, wyrasta na Steve'a Jobsa nowoczesnych języków programowania — przynajmniej jeśli idzie o sposób prezentacji swoich dzieł. Oto kilka dni temu na grupie dyskusyjnej Clojure pojawiło się ogłoszenie, że Rich wystąpi na najbliższym spotkaniu nowojorskiej grupy użytkowników Clojure, że strumień wideo z wystąpienia będzie nadawany na żywo i że zaprezentowane zostanie “coś nowego”.

Tym czymś okazał się ClojureScript: kompilator Clojure do JavaScriptu, łączący moc pierwszego języka z powszechnością drugiego.

JavaScript bywa nazywany “czarnym koniem” języków programowania, najbardziej niedocenianym językiem minionej dekady i najważniejszym obecnej. Istotnie, silniki JavaScriptu w przeglądarkach — a także wolnostojące, jak Node.js — poczyniły w ostatnich latach kolosalne postępy, jeśli chodzi o wydajność, narzędzie to jest coraz chętniej używane do programowania coraz poważniejszych aplikacji i nie mijając się z prawdą można powiedzieć, że JavaScript jest wszędzie.

A teraz te same słowa można odnieść również do Clojure. W tegorocznym sondażu, podobnie jak rok temu, społeczność Clojure najczęściej wskazywała JavaScript jako hipotetyczną alternatywną wobec JVM platformę, ale zapewne mało kto się spodziewał, że urzeczywistni się to tak szybko. Łatwo sobie uzmysłowić możliwości, jakie daje nowa implementacja. Ten sam kod na serwerze i w przeglądarce? Programowanie aplikacji iOS w Clojure? Jak najbardziej!

W ramach dodawania łyżki dziegciu do tej beczki miodu trzeba zauważyć jednak, że dialekt ClojureScript jest okrojony i ma nieco inną semantykę niż standardowy Clojure: nie ma STM, niezmienność struktur danych nie jest wymuszana, wektory są zwykłymi JavaScriptowymi tablicami. Czy to się zmieni, pokaże przyszłość.

Jaka przyszłość czeka Clojure i ClojureScript? Spróbuję pokusić się o przepowiednię:

  • Pojawienie się alternatywnej wobec JVM platformy wymusi ukształtowanie się bogatszej niż do tej pory biblioteki standardowej Clojure, wspólnej dla obu wersji. Ten proces trwa już teraz: tam, gdzie w czasach Clojure 1.0 pisało się (.toUpperCase "foo") (wychodząc z założenia where Java isn’t broken, Clojure doesn’t fix it), teraz kanoniczną formą jest (clojure.string/upper-case "foo"). W ten sposób Clojure stopniowo stanie się językiem niezabawkowym według definicji Maćka Pasternackiego.
  • ClojureScript otworzy dla Clojure zupełnie nową niszę: stanie się popularny wśród programistów-frontendowców, piszących do tej pory głównie w JavaScripcie lub CoffeeScripcie.
  • Równocześnie pojawienie się ClojureScript nie oznacza, że zatrzymają się prace nad “tradycyjnym” Clojure. JVM pozostanie główną platformą rozwoju języka i to tam będą pojawiać się kolejne innowacyjne cechy. Wersja 1.3 jest już w fazie beta i nadanie jej statusu final jest kwestią kilku tygodni.

Patrząc wstecz, aż trudno uwierzyć, jak dużą popularność zdobyło sobie Clojure. Gdyby ktoś mi cztery lata temu powiedział, że za cztery lata pojawi się nowy język z rodziny Lisp, który będzie znacznie bardziej elegancki niż Common Lisp, znacznie praktyczniejszy niż Scheme i używać go będą wielkie instytucje finansowe, wyśmiałbym go. A jednak: zaczynam wierzyć, że prawo Kopernika jednak nie stosuje się w informatyce.

Posted on 2011-07-21

Clojure: czyszczenie dowiązań lokalnych

O jednej z nowych cech Clojure 1.2 dowiedziałem się dopiero niedawno, bo jej wprowadzenie przeszło właściwie bez echa. Może dlatego, że to optymalizacja niewidoczna na zewnątrz; jednak rozwiązuje ważny problem i dlatego warto mieć świadomość jej istnienia. Chodzi o locals clearing, które tłumaczę na polski jako “czyszczenie lokalnych dowiązań”.

Wyobraźmy sobie taką sytuację: mamy do wykonania skomplikowane obliczenie. Możemy je podzielić na mniejsze kroki; na każdym etapie wyliczamy nowy pośredni wynik na podstawie tylko niewielu ostatnich kroków — jednego, może dwóch. Załóżmy jeszcze, że te pośrednie wyniki są dużych rozmiarów: powiedzmy setek megabajtów. To częsty przypadek, kiedy wykonuje się np. obliczenia na dużych, gęstych macierzach: najpierw normalizujemy każdy z wektorów naszej macierzy, potem ją transponujemy, następnie liczymy jej rozkład własny i odrzucamy część wektorów własnych.

W Javie wyglądałoby to tak:

void calculate(Matrix m) {
    Matrix m1 = step1(m);
    Matrix m2 = step2(m1);
    // i tak dalej, każdy krok odwołuje się do poprzedniego
}

Jest to jednak prosta droga do pojawienia się OutOfMemoryError, o ile nie dysponujemy wystarczającą ilością pamięci, aby pomieścić wszystkie kroki. Gdy m2 jest obliczone, wartość m1 jest już niepotrzebna i w zasadzie można by zwolnić zajmowaną przez nią pamięć. Odśmiecacz jednak tego nie zrobi, dopóki wywołanie funkcji calculate nie zakończy się, ponieważ do tej pamięci odwołuje się ramka na stosie wywołań.

Zilustrujmy to uruchamialnym przykładem:

public class foo {
    public static byte[] calculate(byte[] lastStep) {
        return new byte[10485760];
    }

    public static void main(String... args) {
        byte[] a, b, c, d;
        a = calculate(null);
        b = calculate(a);
        c = calculate(b);
        d = calculate(c);
        System.out.println("OK");
    }
}

Funkcja calculate emuluje skomplikowane obliczenia, alokując za każdym razem 10-megabajtowy blok pamięci. Jeśli skompilujemy ten program i uruchomimy go w środowisku z maksymalnym rozmiarem sterty ograniczonym do 30 MB (java -Xmx30M foo), zobaczymy błąd braku pamięci.

Inaczej jest w Clojure. Odpowiednik powyższego programu wygląda tak:

(defn calculate [last-step]
  (make-array Byte/TYPE 10485760))

(defn -main [& args]
  (let [a (calculate nil)
        b (calculate a)
        c (calculate b)
        d (calculate c)]
    (println "OK")))

W Clojure 1.2 i nowszych ten program wypisze OK, nawet gdy będzie miał do dyspozycji tylko 30 MB pamięci.

Dlaczego to działa? Okazuje się, że kompilator Clojure przeprowadza statyczną analizę każdego kawałka kodu, w którym wartości są dowiązane do nazw (czyli każdego użycia formuły let), i dla każdego dowiązania sprawdza, w którym miejscu jest ono ostatni raz używane, biorąc pod uwagę wszystkie możliwe ścieżki wykonania. Natychmiast po ostatnim użyciu emitowany jest bajtkod zerujący odpowiednią zmienną (ustawiający wskaźnik na null). Innymi słowy, efekt jest taki, jakbyśmy w przykładowym kodzie w Javie wstawili instrukcję a = null; zaraz po obliczeniu b i analogicznie dalej.

O jednej z sytuacji, w których takie czyszczenie się przydaje, już powiedziałem. Innym częstym przypadkiem jest branie za punkt wyjścia długiej (być może nawet nieskończonej) leniwej sekwencji, która jest realizowana w kolejnych krokach obliczeń. Dzięki automatycznemu czyszczeniu odśmiecacz może pozbyć się pierwszych, niepotrzebnych już elementów zrealizowanej sekwencji, co umożliwia pisanie klarownego kodu bez troszczenia się o jego poprawność pamięciową.

Jak jednak napisał mi Rich Hickey, sama alokacja pamięci w momencie wiązania nazwy nie jest uważana za użycie tej nazwy. Stąd jeśli nazwa nigdy nie zostanie użyta, to odpowiadająca jej wartość nie zostanie też odśmiecona aż do opuszczenia ciała bloku let. Dlatego też nie mogłem pominąć w powyższym przykładzie argumentu last-step funkcji calculate. Jednak taki przypadek nie pojawia się w praktyce: wszak jeśli wyliczamy jakiś wynik, to nie po to, by go do niczego nie użyć!

Posted on 2011-01-31

Pierwsze użycie: protokoły i git-bisect

Lubię rozwiązywać problemy przy użyciu narzędzi, których dotychczas nie znałem albo znałem tylko teoretycznie, na zasadzie “wiem, że istnieje coś takiego i do czego z grubsza służy”. Jeszcze przyjemniej jest, kiedy uzyskane rozwiązanie okazuje się czytelniejsze, zrozumialsze, szybsze albo pod innymi względami lepsze niż wersja używająca tylko dotychczasowego “arsenału”. W tych dniach zdarzyło mi się tego doświadczyć dwukrotnie, gdy pracowałem nad Fablo.

Fablo to napisany w Clojure silnik wyszukiwarki dla polskich sklepów internetowych: zna odmianę polskich wyrazów, obsługuje literówki i błędy ortograficzne. Radzi sobie też z wyrazami wpisanymi bez polskich znaków, a więc wie, że “papryka zolta” to to samo, co “papryka żółta”. Działa to na ogół zupełnie dobrze, ale wciąż ulepszamy wyszukiwarkę i eliminujemy błędy polegające na tym, że na jakieś zapytanie nie wyszukują się niektóre produkty, choć powinny. Tym razem błąd brzmiał: zapytanie “sledzie” zwraca inne wyniki niż “śledzie”.

Analiza problemu ujawniła, że algorytm odpowiedzialny za “polszczenie” zapytania powinien być stosowany szerzej. Mamy dwie implementacje pewnej struktury danych używanej w tym algorytmie: jedna z nich jest czysto funkcyjna i zbudowana z rdzennie clojurowego tworzywa — map i wektorów; druga zaś jest ukryta za fasadą używanej przez nas biblioteki Javowej o prostym interfejsie, a więc explicite do tej pory z niej nie korzystaliśmy. Algorytm polszczenia był uruchamiany tylko na pierwszej implementacji (co wystarczy w wielu przypadkach, ale nie zawsze), a powinien być na obu.

Koncepcyjnie te dwie implementacje są podobne, ale mają zupełnie różne API — zrazu wydawało mi się więc, że będę musiał pisać drugą wersję funkcji realizującej “polszczenie”. To jednak nie byłoby optymalne: duplikacja kodu oznacza dwa razy więcej okazji do popełnienia błędu i konieczność pamiętania o wprowadzeniu zmian każdorazowo w obu miejscach. Z pomocą przyszły protokoły — nowy element języka wprowadzony w Clojure 1.2.

Protokół jest czymś bardzo zbliżonym do znanego z Javy interfejsu: to zestaw deklaracji nazwanych funkcji i ich argumentów, jednak bez implementacji. Implementacje definiuje się dla konkretnych typów. Mogą to być zarówno typy definiowane w Clojure (przy użyciu konstrukcji deftype), jak i istniejące klasy Javowe. Można więc zadeklarować w protokole jakąś operację (nazwijmy ją blabalizacją), po czym zdefiniować, co oznacza blabalizowanie liczb, a co napisów. Efekt jest taki, jak gdybyśmy w Javie jakoś zmusili klasy java.lang.Integer i java.lang.String do implementowania interfejsu Blabalize; możemy wołać odpowiednią funkcję bezpośrednio na obiektach odpowiednich klas, bez żadnych wrapperów!

Zacząłem więc od zdefiniowania protokołu, opisującego dwie operacje, jakich można dokonywać na mojej strukturze danych. Potem przerobiłem implementację algorytmu “polszczącego” zapytania tak, aby korzystała tylko z tych dwóch operacji, co uniezależniło ją od implementacji “pod spodem”. Napisanie Clojurowej implementacji protokołu było proste; trudniejsze okazało się odpowiednie skorzystanie z biblioteki Javowej, ale i to udało się zrobić w miarę szybko.

Efekt: brak duplikacji kodu i tylko nieznacznie zmodyfikowana dotychczasowa wersja. A przy tym udało się nagiąć bibliotekę Javową do robienia rzeczy, do których nie była zaprojektowana, bez ingerencji w jej kod. Clojure nie przestaje mnie zadziwiać.

Innym razem zauważyłem, że od kilku commitów przy próbie uruchomienia testów jednostkowych i regresji w logach testowania pojawia się komunikat, którego zdecydowanie nie powinno tam być. Mógłbym zabrać się do tego testując okolice pojawienia się komunikatu w REPL, ale tym razem postanowiłem zrobić to inaczej. Ponieważ używamy Git do kontroli wersji, wykorzystałem narzędzie git-bisect, aby znaleźć commit wprowadzający komunikat. Potrzebuje ono do działania trzech rzeczy: identyfikatorów commitu “dobrego” i “złego” oraz skryptu, który odpowiada na pytanie, czy dany commit jest “dobry” czy “zły” (zwracającego kod wyjścia odpowiednio 0 albo 1). Skrypt wyglądał mniej więcej następująco:

#!/bin/bash
cake clean
cake deps
cake proto
sleep 10
cake release
rm *.log
make test
if grep -q "PODEJRZANY_KOMUNIKAT" fablo*log; then
  exit 1
else
  exit 0
fi

(Cake jest narzędziem, którego używamy do budowania oprogramowania; 10-sekundowa przerwa między cake proto a cake release jest remedium na błąd w cake.)

Potem wystarczyło tylko:

$ git bisect start HEAD dobry_commit --
$ git bisect run check.sh

i już wiedziałem, w którym miejscu się zepsuło, a stąd było już niedaleko do znalezienia przyczyny problemu.

Posted on 2011-01-13

Filmujmy błędy!

Każdy programista (o ile jego oprogramowanie jest używane przez kogoś jeszcze poza nim samym) dostał kiedyś zgłoszenie błędu w programie. Wielu pracuje na co dzień z systemami śledzenia błędów, wielu zgłasza błędy w programach, których sami używają.

Dobre zgłoszenie powinno pomóc programiście odtworzyć błąd. Instrukcja odtworzenia jest bodaj najważniejszym z trzech nieodzownych elementów zgłoszenia. (Pozostałe dwa, jak pisze Joel Spolsky, to odpowiedzi na pytania: co powinno było się stać oraz co naprawdę się stało.) Na ogół w systemach śledzenia błędów opisujemy po prostu prozą kroki, które wykonaliśmy, a które doprowadziły do błędnej sytuacji.

Dokładne opisanie wykonanych czynności jest jednak trudne. Znaczenie może mieć każdy, nawet najmniejszy szczegół, łatwo więc o nieścisłość. Zamiast “otworzyłem plik” lepiej napisać “nacisnąłem Alt-O” albo “kliknąłem w menu Plik i kliknąłem polecenie Otwórz”, w zależności od tego, co naprawdę zrobiliśmy. W świetnym eseju Jak efektywnie zgłaszać błędy, z którego zaczerpnąłem ten przykład, Simon Tatham podaje inną, efektywniejszą metodę zgłaszania: należy posadzić programistę przed swoim komputerem i pokazać mu, w jaki sposób doprowadzamy do błędu.

Ta metoda jest dobra, kiedy da się zastosować. Problem pojawia się w przypadku, kiedy programista jest fizycznie na drugim końcu świata. Nie może wtedy zobaczyć monitora użytkownika i patrzeć mu na ręce… albo przynajmniej do niedawna nie mógł.

Od jakiegoś czasu w Fablo dołączamy do zgłoszeń błędów krótkie filmiki — screencasty z komentarzem głosowym. Z każdym kolejnym błędem rozwiązanym dzięki wskazaniu w taki sposób coraz bardziej przekonuję się do rozlicznych zalet tej metody. Przede wszystkim film jest dokładnym zapisem sesji. Widać wszystko, co się działo krok po kroku, i nie trzeba prosić o dodatkowe szczegóły, co zabiera czas i naprawiającego, i zgłaszającego. Film można odtwarzać wielokrotnie i dowolnie po nim nawigować, a nawet spowalniać; to nawet lepsze niż patrzenie na żywo na pracę użytkownika z programem. Jeśli mamy do czynienia z heisenbugiem (“wywala się mniej więcej co pięć minut i w różnych sytuacjach, za cholerę nie mam pomysłu!”), możemy po prostu włączyć nagrywanie i zapomnieć o nim, pracując normalnie do momentu wystąpienia błędu. Film można wtedy obciąć do ostatnich dwóch-trzech minut i wysłać.

Co więcej, sporządzenie filmiku bardzo mało kosztuje. Na obie używane przez nas platformy dostępne są narzędzia pozwalające łatwo nagrać film. Dzięki Snapz Pro X na Mac OS X nagranie jest tak proste jak naciśnięcie Shift-Cmd-3 na początku i na końcu filmu; Record My Desktop na Linuksa i FreeBSD jest nieco trudniejszy w obsłudze, ale wciąż łatwy.

Wypadałoby wspomnieć o minusach filmów. Przychodzą mi do głowy dwa. Jednym jest fakt, że nie każdy błąd można zgłosić w ten sposób, a mówiąc ściślej: błąd w nie każdym oprogramowaniu. Gdy pracuje się z bibliotekami zamiast z okienkowym lub przeglądarkowym oprogramowaniem, wyłapywane błędy są innej natury i polegają na ogół na nieoczekiwanym zachowaniu konkretnej funkcji lub klasy. To, nolens volens, trzeba opisać.

Druga sprawa to ilość miejsca zajmowanego przez filmiki i czasu potrzebnego na ich wrzucenie do systemu kontroli błędów. To zapewne jeszcze do niedawna był powód, dla którego rzadko widzi się takie zgłoszenia np. w oprogramowaniu open source. Jednak problem ten staje się mało znaczący wraz z upowszechnieniem się szybkich łącz. Typowa wielkość naszego filmiku to ok. 1.5-2 MB; to naprawdę nie jest dużo, a FogBugz pozwala na bardzo łatwe — jednym przeciągnięciem myszy — dodanie screencastu do raportu błędu. Większe rzeczy z łatwością można udostępnić w Dropbox.

Filmujmy więc błędy!

Posted on 2010-11-19