Zasada Kolejności Przekształceń

Ten wpis proponuje raczej radykalne przesłanki. Sugeruje, że Refaktoringi mają odpowiedniki nazywane Przekształceniami. Refaktoringi są prostymi operacjami, które zmieniają strukturę kodu bez zmiany jego zachowania. Przekształcenia są prostymi operacjami, które zmieniają zachowanie kodu. Przekształcenia mogą być użyte jako jedyny sposób spełniania aktualnie nieprzechodzącego testu w cyklu red/green/refactor. Przekształcenia mają priorytet, preferowaną kolejność, która zapobiega blokadom lub długim przerwom w pracy w cyklu red/green/refactor.

"Gdy testy stają się bardziej konkretne, kod staje się bardziej ogólny."

Ostatnio ta mantra nabrała dla mnie nowego znaczenia.



Poniższy tekst jest luźnym tłumaczeniem wpisu bloga Roberta Cecila "Wujka Boba" Martina ze strony :
https://8thlight.com/blog/uncle-bob/2013/05/27/TheTransformationPriorityPremise.html
Proszę o komentarze, jeżeli ta luźność jest zbyt daleko posunięta.




Wynalazłem tę zasadę, aby uchronić moich uczniów przed paskudnym nawykiem pisania kodu produkcyjnego odzwierciedlającego kod testowy:


@Test
public void primeFactorsOfFour() {
  assertEquals(asList(),    PrimeFactors.of(1));
  assertEquals(asList(2),   PrimeFactors.of(2));
  assertEquals(asList(3),   PrimeFactors.of(3));
  assertEquals(asList(2,2), PrimeFactors.of(4));
  ...
}

public class PrimeFactors {
  public static of(int n) {
    if (n == 1)
      return asList();
    else if (n == 2)
      return asList(2);
    else if (n == 3)
      return asList(3);
    else if (n == 4)
      return asList(2,2);
    ...

Nowicjusze w TDD często pytają dlaczego TDD nie prowadzi do tego typu kodu. Odpowiadam, że z powodu powyższej zasady. Wyjaśnienie zwykle zaspokaja uczniów, zwłaszcza, kiedy pokazuje im ten pomysł na przykładzie Kata Liczb Pierwszych.

Liczby Pierwsze 

Wymyśliłem to Kata dziesięć lat temu, kiedy mój syn Justin przyszedł do domu z pracą domową. Miał za zadanie policzyć liczby pierwsze kilku liczb całkowitych. Powiedziałem mu żeby zrobił tę pracę domową, a ja napiszę program, który pozwoli mu sprawdzić jego pracę. (On wpisze odpowiedź, a program po prostu powie mu czy miał rację, czy się pomylił).

Usiadłem w kuchni przy stole i używając nowej dyscypliny o nazwie TDD, napisałem algorytm w Ruby. To było dla mnie jedno z tych wydarzeń otwierających oczy. Jak przechodziłem od testu do testu algorytm tworzył się w kompletnie nieoczekiwany sposób. Byłem zdumiony, że mogę sprawić, żeby test numer 3 przechodził tylko przez zmianę jednego znaku z '2' na 'n'. Byłem wstrząśnięty kiedy przypadek numer 8 przechodził przez zmianę słowa 'if' na słowo 'while'. Czułem, że w tym jest coś głębszego, ale nie mogłem wskazać co dokładnie. Ale teraz myślę, że wiem.

Durnota

Przez te wszystkie lata napisałem mnóstwo testów. Co więcej, wykonałem jeszcze więcej różnych Kata. Od czasu do czasu dokonuję drobnych poprawek w Kata. Udoskonalam testy i kod, żeby były prostsze, bardziej eleganckie. Z każdym takim powtórzeniem i udoskonaleniem coś zauważam. Ma to związek z jednym z zarzutów, jaki mają ludzie do TDD: a mianowicie durnoty.

Zobacz jak Kata Kręgli zaczyna się testem dla najgorszej możliwej gry:

@Test
public void gutterGame() {
  for (int i=0; i<20; i++)
    game.roll(0);
  assertEquals(0, game.score());
}

Kiedy uczymy TDD zwykle pytamy: "Jak zrobić żeby ten test przeszedł?". Nowicjusze są często zakłopotani tym pytaniem, bo oczekują, że będą musieli  do tego napisać cały algorytm gry w kręgle. Ale zaskakujemy ich pokazując, że test przechodzi dla kodu napisanego w taki sposób:

public int score() {
  return 0;
}

W tym momencie programiści w klasie wywracają oczy i jęczą. Najwyraźniej sądzą, że to durne i są rozbici tym, że każę im pisać kod w sposób ewidentnie zły.

Zwykle wtedy stosuję unik. Zgadzam się z nimi, ze to było durne, i że po prostu odwlekaliśmy decyzję, aż do momentu, gdy będziemy mieć więcej informacji. Mówię im, że to był także świetny sposób na testowanie testów, no bo, jeżeli metoda zwróciła zero, jasno widzimy, że test przechodzi i oznacza to, że test jest prawidłowy.

Porządek Przekształceń.

To, co odkryłem, to to, że zwracanie zera nie jest tak durne jak wygląda. Nie, jeżeli umieścisz je we właściwym otoczeniu.

Kiedy używamy TDD, nasz produkcyjny kod podlega kolejnym przekształceniom. Zwykle myślę o nich jako o przekształceniach od głupich do sprytnych. Ale zacząłem się przekonywać, że to nie o to chodzi. Raczej kod przechodzi przez zestaw przekształceń od bardziej konkretnych do bardziej ogólnych.

Zwracanie zera z funkcji score to przypadek konkretny. Sedno sprawy leży w poprawnej formie. To jest liczba całkowita i ma poprawną wartość. W związku z tym kształt algorytmu jest poprawny, po prostu nie został jeszcze uogólniony.

Następny test w grze w kręgle to:

@Test
public void allOnes() {
  for (int i=0; i<20; i++)
    game.roll(1);
  assertEquals(20, game.score());
}

Sprawiamy żeby przechodził przez dodanie wszystkich punktów za strącone pachołki w funkcji roll i przechowujemy ich sumę w zmiennej o nazwie score. Potem zmieniamy funkcję score, żeby zwracała wartość tej zmiennej:

public int score() {
  return score;
}

Zauważ, że przekształciliśmy stałą 0 w zmienną score. Algorytm ma taki sam kształt, jak wcześniej (na przykład zwraca inta), ale teraz ma bardziej ogólną implementację. Dlaczego ma bardziej ogólną implementację? Ponieważ zmienna jest uogólnieniem stałej.

Innymi słowy, przekształcenie, które miało miejsce, jest prostą zmianą jednej części rozwiązania z formy bardziej specyficznej w formę bardziej ogólną!

Zacząłem myśleć, że to jest całkiem interesujące. Byłem podniecony faktem, że czasami mogłem dokonać pewnych przekształceń z bardziej konkretnych na ogólne. Potem zacząłem podejrzewać, że to jest zasada, że każda zmiana kodu jest albo zmieniającym przekształceniem od konkretnego do ogólnego, albo refaktoringiem. Rzeczywiście, myślę, że te zasada może pokierować wyborem co do następnego testu do napisania, może też wpłynąć na kod produkcyjny który powinien być napisany aby spełnić ten test.

Ale nie uprzedzajmy faktów. Co z następnym testem w Grze w Kręgle?

@Test
public void oneSpare() {
  game.roll(5);
  game.roll(5); // spare
  game.roll(3);
  rollMany(20,0);
  assertEquals(16, g.score());
}

Ten test zmusza nas, aby porzucić prostą implementację metody score na rzecz dużo bardziej skomplikowanej. Zmienna obiektowa o nazwie score uaktualniana w funkcji roll jest usunięta i funkcja score oblicza wynik z tablicy rzutów kulą bilardową.

Po raz kolejny przekształciliśmy konkretną implementację (zmienna, która zawiera wstępnie obliczony wynik) na bardziej ogólną postać (pętla, która oblicza wynik z tablicy).

Inne częste przekształcenie można zauważyć w kata liczb pierwszych, gdzie przy spełnieniu drugiego przypadku, wstawiamy instrukcję if do implementacji. Kod przekształca się z:

List factors = new ArrayList();
return factors;

do:

List factors = new ArrayList();
if (n>1)
  factors.add(2);
return factors;

W tym przypadku tworzymy kod bardziej ogólny przez warunkowe podzielenie wykonania na dwie ścieżki. Jedna ścieżka sprawia, że stary kod przechodzi i druga ścieżka sprawia, że nowy kod przechodzi.

Kata liczb pierwszych jest pod tym względem interesująca, bo przekształcenie dzieje się znowu w kroku numer 4, kiedy to instrukcja if jest dodawana dla przypadku, kiedy wejściowa zmienna jest podzielna przez 2.

List factors = new ArrayList();
if (n>1) {
  if (n%2 == 0) {
    factors.add(2);
    n %= 2;
  }
  if (n>1)
    factors.add(n);
}
return factors;

Nowa ścieżka radzi sobie z krokiem numer 4 poprzez odkrycie, że liczba 4 jest podzielna przez 2, potem jest dodanie 2 do factors i modyfikacja n tak, żeby ścieżki te mogły się połączyć ponownie.

Jeszcze bardziej interesująco jest w kroku numer 8, gdzie wewnętrzna instrukcja if jest przekształcana w instrukcję while.  I potem dla kroku 9 zewnętrzna if jest przekształcana w while. Jasne, że while jest ogólną postacią if.

Przekształcenia

A więc jakie to są przekształcenia? Być może możemy zrobić z nich listę:
  • ({} -> nil) brak kodu w ogóle -> kod, który zwraca nila (nulla) 
  • (nil -> stała)
  • (stała -> stała+) prosta stała w bardziej skomplikowaną stałą
  • (stała -> skalar) zamiana stałej w zmienną, albo argument
  • (instrukcja -> instrukcje) dodawanie więcej niewarunkowych instrukcji
  • (niewarunkowe -> if) rozbijanie ścieżek wykonania
  • (skalar -> tablica)
  • (tablica -> kontener)
  • (instrukcja -> rekurencja)
  • (if -> while)
  • (wyrażenie -> funkcja) zamiana wyrażenia na funkcję lub algorytm 
  • (zmienna -> przypisanie) zmiana wartości zmiennej
Zapewne są też inne. 

Zapewne zauważyłeś podobieństwo tych przekształceń do refaktoringów. Jakkolwiek refaktoringi używane są do przekształcania konstrukcji kodu, bez zmiany jego zachowania. Te przekształcenia, z kolei, są używane do zmiany zachowania kodu. W szczególności, używamy tych przekształceń do sprawiania, żeby przechodziły testy, które nie przechodzą.

Powinno być jasne, że każde przekształcenie posiada kierunek. One wszystkie przekształcają zachowanie kodu z czegoś konkretnego na coś bardziej ogólnego. W niektórych przypadkach to stała jest przekształcana w zmienną lub zmienna jest przekształcana w tablicę. W innych to instrukcja if jest przekształcana w pętlę while, albo prosta sekwencja jest przekształcana w rekurencję.

Powinno też być jasne, że z grubsza posortowałem tę listę po złożoności jej elementów. Tak jest, przekształcenia na górze listy są prostsze i mniej ryzykowne, niż przekształcenia na dole listy.

Zasada kolejności

To, co jest ostatnio przedmiotem mojego zainteresowania, to pomysł, że przekształcenia na szczycie listy powinny być chętniej wybierane niż te na dole listy. Jest lepiej (lub jak kto woli łatwiej) zmienić stałą w zmienną, niż dodać warunek if. A więc kiedy sprawiasz, żeby test przechodził - robisz to przy użyciu przekształceń, które są prostsze (wyżej na liście) niż bardziej złożone.

Co więcej, gdy tworzysz test, starasz się, aby pozwalał on na łatwiejsze przekształcenia bardziej, niż na złożone przekształcenia; ponieważ im większa złożoność jest wymagana przez test tym większe ryzyko, że nie sprawisz, żeby ten test przechodził.

Problem blokady. 

To Kata Zawijania Wierszy sprawiła, że zacząłem o tym myśleć. Kata zaczyna się dość prosto, ale szybko stajemy przed zagwozdką. Jest tam jedna kolejność testów i decyzji co do implementacji, które stawiają Cię w martwym punkcie, w którym nie ma innej drogi, aby następny test przechodził jak przez napisanie całego algorytmu. Inna kolejność testów pozwala stworzyć algorytm krok po kroku w sposób preferowany przez TDDersów. Jak wybrać właściwe następstwo testów?

To jest stosunkowo często spotykany problem przez TDDersów. Tworzymy test tylko po to, żeby dowiedzieć się, że nie wiemy jak to rozwiązać bez zmiany ogromnej ilości kodu. A im więcej kodu będziemy zmieniać, tym dłużej nam zajmie powrót do koloru zielonego; i cykl red/green/refactor załamie się.

Moja propozycja jest taka, że jeżeli wybierzesz testy i implementacje, które będą wykorzystywać przekształcenia z góry listy, to unikniesz blokady.

Konkretny przypadek :  Zawijanie Wierszy.

Pójdziemy po kolei tokiem rozumowania. Najpierw odpalimy katę zawijania wierszy i wybierzemy ścieżkę, która prowadzi do ślepego zaułka. Potem zrobimy to znów, ale nie pójdziemy tą ścieżką. W każdym przypadku pokażemy przekształcenia.

Pierwszy test w kata zawijania wierszy jest całkiem oczywistym przypadkiem zdegenerowanym. Zauważ, że stosuję pierwsze przekształcenie ({} -> nil):

@Test
public void WrapNullReturnsEmptyString() throws Exception {
  assertThat(wrap(null, 10), is(""));
}


Po napisaniu tego testu, piszemy także nieprzechodzącą implementację, która także korzysta z ({} -> nil) 

public static String wrap(String s, int length) {
  return null;
}


Możemy sprawić, żeby ten test przechodził przez (nil -> stała)

public static String wrap(String s, int length) {
  return "";
}


Następnym testem będzie przypadek pustego stringa. Zauważ, że to proste (nil -> stała) zastosowane do pierwszego testu. Test przechodzi bez zmiany w implementacji. Zwykle uważam to za znak, że wszystko idzie dobrze.

@Test
public void WrapEmptyStringReturnsEmptyString() throws Exception {
  assertThat(wrap("", 10), is(""));
}


Następny test stosuje (stała -> stała+)

@Test
public void OneShortWordDoesNotWrap() throws Exception {
  assertThat(wrap("word", 5), is("word"));
}


Aby ten test przechodził jesteśmy zmuszeni użyć (niewarunkowe -> if) jak i (stała -> skalar)

public static String wrap(String s, int length) {
  if (s == null)
    return "";
  return s;
}


Impas

W tym miejscu, jeżeli przyglądaliśmy się uważnie zasadzie kolejności, możemy się zastanawiać czy to był mądry krok. W końcu, (niewarunkowe -> if) leży całkiem nisko na liście. Ale w tym przypadku ominę zasadę kolejności, żeby móc zaprezentować blokadę.

Następny test stosuje znowu (stała -> stała+)

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
}


Możemy sprawić, żeby przechodził przez użycie (wyrażenie -> funkcja)

public static String wrap(String s, int length) {
  if (s == null)
    return "";
  return s.replaceAll(" ", "\n");
}

To jest jeden z tych ruchów, który wydaje się sprytny. Uzasadniamy to mówiąc, że robimy najprostszą rzecz, która działa. Ale mając zasadę kolejności, to nie jest już takie proste. Przekształcenie (wyrażenie -> funkcja) jest bardzo nisko na dole listy.

Następny test stosuje (stała -> stała+)

@Test
public void ThreeWordsJustOverTheLimitShouldWrapAtSecondWord() throws Exception {
  assertThat(wrap("word word word", 9), is("word word\nword"));
}

Ale jak zrobić, żeby to przechodziło? Aktualne rozwiązanie w kodzie nie wydaje się być łatwo przekształcalne na coś, co sprawi, żeby test przechodził. Gdybyśmy mieli funkcję w rodzaju replaceLast(" ",  "\n") to byłoby może łatwe; ale to nie pomogłoby nam w następnym przypadku "word word word word".

Jesteśmy w ślepym zaułku. W tym przypadku, to jest prosty problem i nie jest tak naprawdę trudno znaleźć rozwiązanie. Ale nie o to chodzi. Aktualna sytuacja zmusza nas byśmy zrobili dłuższy krok niż chcemy. Postawiliśmy się na pozycji, w której musimy teraz rozwiązać dużą część problemu zamiast małej, przyrostowej części problemu. Musimy postawić krok, który jest niewygodnie duży.

Przełamanie Impasu

A więc wróćmy teraz do punktu, w którym po raz pierwszy pominęliśmy kolejność przekształceń. Stworzyliśmy wtedy następujący test:

@Test
public void OneShortWordDoesNotWrap() throws Exception {
  assertThat(wrap("word", 5), is("word"));
}

Nie ma w tym teście nic niezwykłego, co mogłoby wskazywać, że ten test jest niepełnosprytny. Oczywiście nie możemy napisać lepszego testu. Jednak implementacja zmusza nas byśmy użyli przekształcenia (niewarunkowy -> if), które ma całkiem niski priorytet.

public static String wrap(String s, int length) {
  if (s == null)
    return "";
  return s;
}

Więc teraz powinniśmy zapytać siebie samych, jakie inne testy możemy stworzyć, które mogą przechodzić dla przekształceń z wyższym priorytetem. W tym momencie implementacją jest po prostu return ""; więc czy są inne wejścia, które powodują zwrócenie pustego stringa?

Długość kolumny, poniżej pewnej wartości jest bez sensu. Możemy dla tego przypadku zwrócić pustego stringa, ale możemy też wyrzucić wyjątek. Myślę, że wyjątek jest prawdopodobnie bardziej właściwy. Ale testy dla tego przypadku wykorzystywałyby także przekształcenie (niewarunkowy -> if). Nadal, to prawdopodobnie dobry pomysł zacząć od przypadków niepoprawnego wejścia.

@Test(expected = WordWrapper.InvalidArgument.class)
public void LengthLessThanOneShouldThrowInvalidArgument() throws Exception {
  wrap("xxx", 0);
}


Który przechodzi przez:

public static String wrap(String s, int length) {
  if (length < 1)
    throw new InvalidArgument();
  return "";
}


Ale to ustawia nas w sytuacji, która była już wcześniej. Więc zgaduję, że nie ma lepszego testu do napisania:

@Test
public void OneShortWordDoesNotWrap() throws Exception {
  assertThat(wrap("word", 5), is("word"));
}


I po (niewarunkowy -> if) i po (stała -> skalar) implementacja wygląda tak:

public static String wrap(String s, int length) {
  if (length < 1)
    throw new InvalidArgument();
  if (s == null)
    return "";

  return s;
}

No więc teraz znowu tworzymy test word word. Tak jak wcześniej to jest zwykłe przekształcenie (stała -> stała+).

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
}

Ostatni raz, kiedy widzieliśmy ten test, sprawiliśmy, żeby działał przy użyciu (wyrażenie -> funkcja). Czy on może być rozwiązany przy użyciu przekształcenia o wyższym priorytecie? Nie wydaje mi się. Każde rozwiązanie, które biorę pod uwagę wymaga zastosowania jakiejś formy algorytmu.

Czy jest inny test, który możemy stworzyć, który może być rozwiązany przy użyciu przekształcenia wyższego priorytetu? Tak, jest taki! Więc zignorujmy (@Ignore) aktualny test i napiszmy taki, który używa prostszego przekształcenia.

@Test
public void WordLongerThanLengthBreaksAtLength() throws Exception {
  assertThat(wrap("longword", 4), is("long\nword"));
}


Ten test może przechodzić dzięki przekształceniu (niewarunkowy -> if).

public static String wrap(String s, int length) {
  if (length < 1)
    throw new InvalidArgument();
  if (s == null)
    return "";

  if (s.length() <= length)
    return s;
  else {
    return "long\nword";
  }
}

To może wyglądać jak oszukaństwo; ale nim nie jest. Rozbiliśmy ścieżki wykonania i nowa ścieżka może być początkowo widziana jako zupełnie pusta, a potem może być przekształcone przez ({}->nil) i (null -> stała). Moglibyśmy napisać te przekształcenia i zobaczyć, że przechodzą; ale po co?

Następny test jest całkowicie oczywisty. Musimy pozbyć się tej stałej. Możemy to zrobić poprzez dodanie nowej instrukcji do istniejącego testu przy użyciu przekształcenia (instrukcja -> instrukcje).

@Test
public void WordLongerThanLengthBreaksAtLength() throws Exception {
  assertThat(wrap("longword", 4), is("long\nword"));
  assertThat(wrap("longerword", 6), is("longer\nword"));
}


To będzie wymagać zastosowania (wyrażenie -> funkcja) do tego, aby przechodziło. Nie ma prostszego przekształcenia i prostszego testu.

else {
    return s.substring(0, length) + "\n" + s.substring(length);
  }


Następny test jest liczbą mnogą dla poprzedniego:

@Test
public void WordLongerThanTwiceLengthShouldBreakTwice() throws Exception {
  assertThat(wrap("verylongword", 4), is("very\nlong\nword"));
}


Możemy sprawić, żeby przechodził poprzez (instrukcja -> rekurencja).

return s.substring(0, length) + "\n" + wrap (s.substring(length), length);


Jest także możliwe spełnienie tego testu przez (if -> while). W rzeczywistości mógłbyś zapytać dlaczego umieściłem (instrukcja -> rekurencja) ponad (if -> while). Więc trochę dalej w tym artykule zbadamy iteracyjne rozwiązanie. Porównanie tych dwóch może przekonać Cię, że rekurencja jest w rzeczywistości prostsza niż iteracja.

Więc teraz wróćmy do pominiętego testu (@Ignore) i włączmy go z powrotem. Jak teraz sprawić, żeby przechodził?

if (s.length() <= length)
    return s;
  else {
    int space = s.indexOf(" ");
    if (space >= 0)
      return "word\nword";
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);
  }

Przekształcenie (niewarunkowy -> if), a następnie przekształcenie (nil -> stała) robią robotę. Co więcej nie można stworzyć prostszego testu, który przechodził, ani użyć prostszego przekształcenia.

Pozbycie się stałej będzie wymagało dodatkowego testu:

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
  assertThat(wrap("wrap here", 6), is("wrap\nhere"));
}

Który przechodzi przy użyciu przekształcenia (wyrażenie -> funkcja). Znowu, nie ma prostszego testu czy przekształcenia. (Ze względu na zwięzłość, i aby ten artykuł nie brzmiał jak zdarta płyta przestaję powtarzać to zdanie. Przyjmij to za pewnik dla wszystkich testów i przekształceń poniżej.)

int space = s.indexOf(" ");
    if (space >= 0)
      return s.substring(0, space) + "\n" + s.substring(space+1);
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);


Teraz widzimy, że nowy warunek potrzebuje przekształcenia (instrukcja -> rekurencja). Więc piszemy test, który to wymusza:

@Test
public void ThreeWordsEachLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word word", 6), is("word\nword\nword"));
}


Zrobienie tego, żeby przechodziło jest proste:

if (space >= 0)
      return s.substring(0, space) + "\n" + wrap(s.substring(space+1), length);
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);


Teraz możemy zrefaktorować kod, aby usunąć powtórzenia.

public class WordWrapper {
  private int length;

  public WordWrapper(int length) {
    this.length = length;
  }

  public static String wrap(String s, int length) {
    return new WordWrapper(length).wrap(s);
  }

  public String wrap(String s) {
    if (length < 1)
      throw new InvalidArgument();
    if (s == null)
      return "";

    if (s.length() <= length)
      return s;
    else {
      int space = s.indexOf(" ");
      if (space >= 0) 
        return breakBetween(s, space, space + 1);
      else
        return breakBetween(s, length, length);
    }
  }

  private String breakBetween(String s, int start, int end) {
    return s.substring(0, start) + 
      "\n" + 
      wrap(s.substring(end), length);
  }

  public static class InvalidArgument extends RuntimeException {
  }
}


Następny test upewnia nas, że łamiemy linię na ostatniej spacji przed limitem.

@Test
public void ThreeWordsJustOverTheLimitShouldBreakAtSecond() throws Exception {
  assertThat(wrap("word word word", 11), is("word word\nword"));
}


To wymaga przekształcenia (wyrażenie -> funkcja), ale jest tak proste, że wydaje się oczywiste.

int space = s.lastIndexOf(" ");


Chociaż to spełnia nowy test, to łamie poprzedni test; ale możemy zrobić jeszcze jedno przekształcenie (wyrażenie -> funkcja) żeby to naprawić.

int space = s.substring(0, length).lastIndexOf(" ");


Gdziekolwiek limity są w użyciu, musi by rozważona relacja trychotomiczna. Wszystkie długości użyte w testach jednoznacznie wykraczały poza zawijającą spację. A co jeżeli będziemy łamać tekst dokładnie na pozycji spacji?

@Test
public void TwoWordsTheFirstEndingAtTheLimit() throws Exception {
  assertThat(wrap("word word", 4), is("word\nword"));
}


To nie przechodzi, ale można zrobić żeby przechodziło przez użycie przekształcenia (instrukcja -> funkcja).

int space = s.substring(0, length+1).lastIndexOf(" ");


To może nie wyglądać jak przekształcenie (instrukcja -> funkcja), ale jest nim w rzeczywistości. Dodawanie jest funkcją. Równie dobrze moglibyśmy napisać add(length, 1).

Iteracja zamiast Rekurencji

A teraz cofnijmy wskazówki zegara i sprawdźmy, jak rozwinie się podejście iteracyjne w porównaniu z podejściem rekurencyjnym. Pamiętasz, jak zastosowaliśmy (instrukcja -> rekurencja) kiedy  próbowaliśmy spełnić następujący test:

@Test
public void WordLongerThanTwiceLengthShouldBreakTwice() throws Exception {
  assertThat(wrap("verylongword", 4), is("very\nlong\nword"));
}

Kod, który powodował nieprzechodzenie testu wygląda tak:

if (s.length() <= length)
    return s;
  else {
    return s.substring(0, length) + "\n" + s.substring(length);
  }


Możemy sprawić, żeby to przechodziło używając przekształcenia (if -> while). Jeżeli chcemy użyć pętli while, to musimy odwrócić warunek instrukcji if. To jest prosty refaktoring, a nie przekształcenie:

if (s.length() > length) {
    return s.substring(0, length) + "\n" + s.substring(length);
  } else {
    return s;
  }


Następnie potrzebujemy stworzyć zmienną, która przechowuje stan iteracji. Znowu, to jest refaktoring, a nie przekształcenie.

String result = "";
  if (s.length() > length) {
    result = s.substring(0, length) + "\n" + s.substring(length);
  } else {
    result = s;
  }
  return result;
}


Pętle while nie mogą mieć instrukcji else, więc potrzebujemy usunąć ścieżkę else poprzez czystki w ścieżce if. Znowu, to tylko refaktoring.

String result = "";
  if (s.length() > length) {
    result = s.substring(0, length) + "\n";
    s = s.substring(length);
  }
  result += s;


I teraz możemy zastosować (if -> while) aby test przechodził.

String result = "";
  while (s.length() > length) {
    result += s.substring(0, length) + "\n";
    s = s.substring(length);
  }
  result += s;


Proces

Jeżeli przyjmiemy Zasadę Kolejności możemy zmodyfikować typowy proces TDD red-green refactor o następujące zasady:
  • Jeżeli dążysz do spełnienia testu wybieraj przekształcenia z wyższym priorytetem
  • Jeżeli tworzysz test wybieraj taki, który może być spełniony z użyciem przekształceń z wyższym priorytetem.
  • Kiedy implementacja wymaga przekształceń o niskim priorytecie, wróć, żeby zobaczyć czy jest gdzieś tam łatwiejszy test do przejścia.

Problemy

Jest też z tym kilka problemów.
  • Czy istnieją inne przekształcenia? (prawie na pewno)
  • Czy te przekształcenia są właściwe? (pewnie nie)
  • Czy istnieją lepsze nazwy dla tych przekształceń? (prawie na pewno)
  • Czy tu chodzi naprawdę o priorytet? (Tak myślę, ale to może być bardziej skomplikowane niż zwykła sekwencja porządkowa)
  • Jeżeli tak, to jaka jest zasada za tym priorytetem? (jakieś pojęcie "złożoności")
  • Czy można to określić ilościowo, kwantowo? (nie mam pojęcia)
  • Czy kolejność priorytetów zaprezentowana w tym artykule jest poprawna? (nie wydaje mi się)
  • Opisane tu przekształcenia opisane są w najlepszym razie w sposób nieformalny. Czy mogą być sformalizowane? (To byłby Święty Graal!)
Jak widzisz, po moich uwagach na marginesie, mam wątpliwości co do tych wszystkich pytań. Tego, czego jestem pewien, to to, że istnieje fundamentalna zasada, która gdzieś tam się ukrywa. Myślę, że istnieje zestaw ustalonych i prostych zasad, nawet jeżeli nie wymieniłem ich dobrze. Mam nadzieję, że mogą być sformalizowane. Myślę, że gdzieś tam istnieją zasady wybierania - które przekształcenia należy zastosować, nawet nie tak proste jak zwykła lista kolejności. 


Skutki

Jeżeli moje podejrzenia okażą się prawidłowe, kilka rzeczy może stać się możliwych:
  • Narzędzie wspierające przekształcenia podobne do narzędzia wspierającego refaktoringi.
  • Narzędzie wspierające podpowiadanie przekształceń, które zachowuje kolejność.
  • Zestaw testów, przekształceń i refaktoringów może być formalnym dowodem poprawności. 
Formalny Dowód Poprawności

Ostatni punkt wymaga podkreślenia. Jeśli możesz opisać żądane zachowanie programu przy użyciu zestawu testów; i możesz pokazać krok po kroku, jak każdy test przechodzi przy zastosowaniu formalnych przekształceń i refaktoringów, to właśnie stworzyłeś dowód. 

Co dziwne, dowód jest osiągnięty przez tworzenie algorytmu w sposób krok po kroku. To interesujące w porównaniu z podejściem Dijkstry dowodzenia poprawności wychodząc od algorytmu.


Podsumowanie

Podczas zwykłego procesu TDD red/green/refactor, wydaje się, że faza green może być osiągnięta przez zastosowanie ustalonego zestawu przekształceń zmieniających zachowanie kodu. Te zmiany zmieniają formę kodu z konkretnej na bardziej ogólną. Wydaje się także, że te przekształcenia mają ustalony porządek oparty na złożoności. Ta kolejność może być używana tak w fazie red jak i fazie green procesu TDD. Podczas fazy green wybieramy prostsze przekształcenia. Podczas fazy red tworzymy takie testy, które mogą być spełnione przy użyciu prostszych przekształceń. Przesłaniem tego wpisu jest to, że jeżeli testy będą wybierane i implementowane w ustalonej kolejności przekształceń to możliwe jest zredukowanie lub wyeliminowanie blokad, impasów w TDD.



Powyższy tekst jest luźnym tłumaczeniem wpisu bloga Roberta Cecila "Wujka Boba" Martina ze strony :
https://8thlight.com/blog/uncle-bob/2013/05/27/TheTransformationPriorityPremise.html
Proszę o komentarze, jeżeli ta luźność jest zbyt daleko posunięta.



Brak komentarzy:

Prześlij komentarz