Gdy kod sam się dubluje – sprzątanie pod osłoną testów
Ta historia zaczyna się od czegoś, co każdy zna, choć mało kto nazywa wprost. W dużym projekcie nie da się utrzymać w głowie całego kodu. Piszesz funkcję, która wybiera najlepszą próbę egzaminu ucznia – i pół roku później, w module generującym PDF, potrzebujesz dokładnie tego samego. Że gdzieś to już jest? Wypadło z głowy. Więc piszesz drugi raz. Potem, przy raporcie dla nauczyciela, trzeci. To nie jest lenistwo ani niechlujstwo – to zwykły koszt pamięci. Kod duplikuje się sam, bo w danej chwili po prostu nie pamiętasz, że masz już co reużyć.
To odległy kuzyn mechanizmu, o którym pisałem w serii o odruchach – tam nawyk brał górę nad decyzją, tu ograniczenie pamięci bierze górę nad wiedzą. Efekt bywa podobny: robi się rzecz gorszą, nie chcąc.
Dlaczego duplikacja to nie „ten sam kod”, tylko „ta sama decyzja”
Dwie kopie tej samej logiki nie są groźne dlatego, że zajmują dwa razy więcej miejsca. Są groźne dlatego, że utrwalają decyzję, która musi się zmieniać w dwóch miejscach naraz – a nie będzie. Któregoś dnia poprawiasz zachowanie w jednej kopii. Drugiej nie ruszasz, bo nawet nie pamiętasz, że istnieje. Od tego momentu dwie kopie „tej samej” reguły dają różne wyniki – i to jest najgorszy rodzaj błędu, bo nie wywala się z hukiem, tylko po cichu podaje dwie różne prawdy.
Nie piszę tego teoretycznie. W backendzie mojej aplikacji do egzaminów zawodowych – Django – kilka takich reguł żyło w kilku kopiach naraz. I te kopie się rozjechały.
Najlepsza próba, która na wydruku była inna
Uczeń może podejść do egzaminu wiele razy; liczy się najlepsza próba. Nauczyciel może też ukryć próbę – na przykład taką, która powstała w podejrzanych okolicznościach – i wtedy nie powinna się liczyć. Wybór najlepszej próby wyglądał, w uproszczeniu i po przepisaniu, tak:
# Python – ekran ucznia i panel nauczyciela: pomija próby ukryte
def best_attempt(assigned, student):
return (StudentExamAttempt.objects
.filter(assigned=assigned, student=student,
finished_at__isnull=False, is_hidden=False)
.order_by('-score')
.first())A w module generującym raport PDF z arkusza siedziała druga kopia tego samego wyboru – tyle że przy przepisywaniu wypadł z niej jeden warunek:
# Python – eksport PDF: kopia, w której zabrakło filtra is_hidden
def best_attempt_for_pdf(assigned, student):
return (StudentExamAttempt.objects
.filter(assigned=assigned, student=student,
finished_at__isnull=False)
.order_by('-score')
.first())Różnica to jeden brakujący is_hidden=False. Skutek: nauczyciel ukrywał słabą próbę, na ekranie znikała i jako najlepsza pokazywała się kolejna, widoczna. Ale PDF nadal wybierał tę ukrytą, bo jego kopia nie wiedziała, że coś takiego jak ukrywanie w ogóle istnieje. Ta sama „najlepsza próba” tego samego ucznia była inna na ekranie i na wydruku. Ukrywanie działało tylko na powierzchni – w warstwie, którą ktoś pamiętał zaktualizować.
To nie był jedyny taki rozjazd. Sam wynik procentowy liczyło u mnie pięć osobnych kawałków kodu, i dwa z nich zaokrąglały inaczej: ekran wyników pokazywał 66,7%, a ten sam wynik w PDF-ie i w eksporcie dla szkoły – 67%, bo jedna kopia zaokrąglała do jednego miejsca po przecinku, a druga do całości. Do tego część kopii brała liczbę pytań z długości listy, część z osobnego licznika – więc przy pytaniach usuniętych po fakcie potrafiły policzyć procent z różnego mianownika. Nikt tego nie zaprojektował. To się rozeszło: kopie powstały osobno, a różnice, których w dniu pisania nie było widać, wyszły dopiero na konkretnych danych.
Ta sama choroba po drugiej stronie – w aplikacji mobilnej
Najciekawsze jest to, że backend nie był wyjątkiem. Ten sam produkt ma aplikację mobilną, napisaną osobno – w React Native, w TypeScripcie – i ona niezależnie złapała dokładnie tę samą chorobę. Bo koszt pamięci nie zależy od języka: w drugim, równie dużym projekcie tak samo nie sposób trzymać w głowie, że dana funkcja już istnieje.
Najbardziej namacalny przykład widział każdy użytkownik, choć pewnie nie wiedział, na co patrzy. Ten sam czas trwania egzaminu pokazywał się na liście jako 12:05, a w szczegółach jako 12 min 5 s – bo formatowanie czasu żyło w siedmiu kopiach, każda odrobinę inna:
// TypeScript – lista egzaminów: zwięźle, mm:ss
function formatDuration(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
// TypeScript – ekran szczegółów: osobna kopia, słownie
function formatDurationText(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m} min ${s} s`;
}Tu akurat żadna wersja nie była błędna – były tylko niespójne, co psuje wrażenie dopracowania, ale nikogo nie kosztuje wyniku. Gdzie duplikacja ugryzła naprawdę, to gest przewijania pytań: skopiowany trzy razy, po jakieś sześćset linii każda kopia, w trybie nauki, ćwiczeń i egzaminu. Za każdym razem, gdy poprawiałem zachowanie swipe’a, musiałem tę samą poprawkę wklepać w trzech miejscach – i to jest dokładnie ta pułapka z początku wpisu: poprawiasz jedną kopię, a dwie inne zostają po staremu, bo w danej chwili nie pamiętasz, że istnieją. Podobnie polska odmiana przez przypadki miała trzy implementacje, z czego dwie potrafiły napisać „22 pytań” albo „5 kwalifikacje mają”.
Ten sam mechanizm, dwa niezależne codebase’y, dwa języki. I – co ważne dla dalszej części – dwa osobne sprzątania, każde pod osłoną własnej suity testów.
Przychodzi czas sprzątania
W końcu przychodzi dzień, w którym siadasz i sprzątasz. I tu jest cały sens tej historii, spinający ją z serią o testowaniu: scalanie kopii to refaktoryzacja, czyli zmiana struktury kodu bez zmiany jego zachowania. A skoro zachowanie ma się nie zmienić, to musi istnieć coś, co to zachowanie zna i pilnuje. Tym czymś jest zielona suita testów – świecąca tak samo przed sprzątaniem i po nim.
Kolejność, która się sprawdza, jest odwrotna do intuicji „najpierw posprzątam, potem dopiszę testy”:
- Najpierw dopisz testy pod kod, który zaraz ruszysz. Jeśli danego fragmentu jeszcze nie pokrywały, teraz jest moment – zanim cokolwiek tkniesz. To się nazywa testy charakteryzujące: nie sprawdzają, jak ma być, tylko zamrażają, jak jest. Zamieniasz obecne zachowanie w asercję.
- Dopiero potem scalaj kopie w jeden serwis. Wszyscy zaczynają wołać jedną funkcję zamiast swoich prywatnych wariantów.
- Suita ma zostać zielona. Jeśli została – scalenie było bezpieczne, nic się nie zmieniło.
I teraz najlepsza część, bo tu testy przestają być formalnością i zaczynają zarabiać. Kiedy scalasz kopie, które się rozjechały, jeden serwis nie może dać naraz wyniku obu wariantów. Ustalasz, która wersja jest poprawna – ukryte próby mają się nie liczyć, kropka – i test pilnujący tej reguły zaświeci na czerwono dla kopii, która liczyła inaczej. Rozjazd, który przez miesiące cicho podawał dwie prawdy, w końcu krzyczy – jako padający test, dokładnie tam, gdzie trzeba. To jest ten moment, w którym czerwony test jest prezentem, nie porażką.
U mnie takie sprzątanie skumulowało się – po obu stronach – w krótkich, gęstych audytach kodu, kilkanaście pozycji jedna po drugiej. Na backendzie liczenie wyniku z pięciu kopii zeszło do jednej funkcji exam_score, z progiem zaliczenia jako jedną nazwaną stałą i z zaokrągleniem podawanym jawnym argumentem, żeby to wołający świadomie wybierał „na ekran” albo „do raportu”, zamiast dziedziczyć różnicę po przypadku. Wybór najlepszej próby zszedł do serwisu, w którym próby ukryte bierze się wyłącznie jawnie – przez include_hidden=True – więc samo zapomnienie o filtrze nie może już po cichu wciągnąć ukrytej próby na wydruk. W aplikacji mobilnej analogiczny audyt ściągnął siedem kopii formatowania czasu do jednego helpera, a trzy kopie przewijania pytań – do jednego, więc kolejna poprawka gestu ma już tylko jedno miejsce do trafienia.
A najciekawszy był strażnik, który przy okazji powstał: test, który porównuje ciała funkcji niezależnie od ich nazwy i pada, jeśli w projekcie znów pojawią się dwie identyczne implementacje. To jest ten sam pomysł, co testy pilnujące treści bloga – asercja postawiona nie na wyniku, lecz na decyzji „nie duplikujemy”. Reguła spisana w teście nie mięknie z czasem tak, jak reguła spisana w dokumentacji.
Sekcja uczciwości: duplikacja nie zawsze jest wrogiem
Gdybym skończył na „scalajcie wszystko, co się powtarza”, dałbym radę gorszą niż problem. Bo zasada DRY – „nie powtarzaj się” – bywa nadużywana, i pogoń za nią potrafi narobić szkód większych niż sama duplikacja:
- Reguła trzech. Dwa podobne kawałki kodu to za mało, żeby wiedzieć, co jest w nich naprawdę wspólne. Warto poczekać na trzecie wystąpienie – dopiero wtedy widać wzorzec, a nie przypadkową zbieżność. Scalanie przy drugim to zgadywanie.
- Zła abstrakcja jest droższa niż duplikacja. Jeśli dwa fragmenty wyglądają dziś identycznie, ale zmieniają się z różnych powodów, sklejenie ich w jedną funkcję tworzy węzeł, który przy każdej zmianie trzeba rozplątywać parametrami i flagami „a jeśli wołający jest stąd, to inaczej”. Wyplątanie się z takiej wspólnej funkcji bywa trudniejsze niż życie z dwiema osobnymi kopiami. Duplikacja jest tania do usunięcia; zła abstrakcja – nie.
- Duplikacja przypadkowa kontra prawdziwa. Prawdziwa to jedna decyzja zapisana w wielu miejscach (jak mój próg zaliczenia). Przypadkowa to dwa różne pomysły, które chwilowo wyglądają tak samo. Scalać wolno tylko tę pierwszą – i czasem świadomie zostawia się coś zdublowanego, gdy scalenie sprzęgłoby rzeczy, które powinny móc się różnić.
I przyznam się do własnego długu, żeby nie zabrzmieć jak ktoś, kto już wszystko posprzątał. Limiter zapytań mojego API wciąż siedzi w dwóch niemal identycznych kopiach, każda z własną stałą liczby żądań na minutę. Warunek „arkusz jest teraz dostępny” powtarza się wpisany wprost w kilku widokach, chociaż mam już na to metodę modelu, do której należałoby go sprowadzić. Wiem o tych miejscach – i to jest właśnie różnica, na której mi zależy: świadomy dług to nie to samo, co niewidoczny rozjazd. Pierwszy czeka w kolejce z etykietą. Drugi cicho podaje dwie prawdy, aż ktoś zauważy.
Puenta
Duplikacja w dużym projekcie jest nieunikniona i nie ma co się nią biczować – rodzi się z tego, że głowa nie utrzyma wszystkiego naraz. Groźne jest nie to, że kod się powtórzył, tylko to, że kopie żyją własnym życiem i po cichu się rozchodzą, aż ta sama najlepsza próba jest inna na ekranie i na wydruku. Sprzątanie tego jest łatwe i bezpieczne pod jednym warunkiem – że wcześniej dopisze się testy, które zamrożą, jak być powinno. Wtedy scalanie kopii przestaje być ryzykiem, a staje się rutyną, a rozjazdy, zamiast chować się w kodzie, wychodzą na wierzch jako czerwone testy. Zielono przed, zielono po – a pośrodku o jedną prawdę mniej, która mogłaby skłamać.