W poprzednim artykule opisałem koncepcję piramidy refaktoryzacji. Zastosowanie tej koncepcji znajduje zastosowanie codziennej pracy oraz w wielu technikach, tj. programowanie sterowane testami czy praca z kodem zastanym.

W dzisiejszych czasach – dzięki narzędziom takich jak IntelliJ lub Eclipse – stosowanie przekształceń kodu jest dosyć proste, gdyż przekształcenia te są często zautomatyzowane. Ponadto, jeżeli rozsądnie korzystamy z takich transformacji, to logika naszego kodu powinna nadal działać zgodnie z oczekiwaniami (a może raczej tak, jak działała wcześniej…). Niektóre wyjątki od tej reguły to transformacje typu “ekstrakcja pola”, ponieważ kiedy metoda ustawia pole zamiast używać zmiennych lokalnych, możemy napotkać problemy z wielowątkowością gdy drugi wątek także uruchomi tą samą metodę w tym samym czasie.

W tym poście chciałbym pokazać piramidę refaktoryzacji w praktyce. Aby to zrobić, będę podążał za kolejnością małych transformacji, jak w jednym z przykładów refaktoryzacji w książce „Refaktoryzacja do wzorców” Joshua Kerievsky’ego. 

Dla przedstawienia przykładu wykorzystam refaktoryzacją do wzorca Interpreter, aczkolwiek po zapoznaniu się z koncepcją „piramidy refaktoryzacji” można dostrzec, że jest ona obecna większości przykładów z tej książki – chociaż nie jest w ten sposób tam nazwana. A to kolei dowód empiryczny, że koncepcja piramidy ma sens.

Zacznijmy od wy przykładowej klasy ProductFinder odpowiedzialnej za wyszukiwanie produktów ze sklepu które spełniają odpowiednie kryteria

public class ProductFinder {
   private List<Product> repository;

   public ProductFinder(List<Product> repository) {
       this.repository = repository;
   }

   public List<Product> byBelowPrice(float price) {
       List<Product> foundProducts = new ArrayList<>();
       Iterator<Product> products = repository.iterator();
       while (products.hasNext()) {
           Product product = products.next();
           if (product.getPrice() < price)
               foundProducts.add(product);
       }
       return foundProducts;
   }

   public List<Product> byColor(ProductColor color) {
      List<Product> foundProducts = new ArrayList<>();
      Iterator<Product> products = repository.iterator();
      while (products.hasNext()) {
          Product product = products.next();
          if (product.getColor().equals(color))
              foundProducts.add(product);
      }
      return foundProducts;
   }
}

Jak widać obie metody są prawie takie same. Jedyną różnicą pomiędzy w/w metodami jest warunek sprawdzenia, czy dany Produkt powinien być zaliczony do listy zawierającej wyniki czy też nie.

*** Poziom metod ***

Wyodrębnijmy na chwilę różnicę (jeden z warunków) do oddzielnej metody i zobaczmy na poniższy kod.

public class ProductFinder {
   private List<Product> repository;

   public ProductFinder(List<Product> repository) {
       this.repository = repository;
   }

   public List<Product> byBelowPrice(float price) {
       List<Product> foundProducts = new ArrayList<>();
       Iterator<Product> products = repository.iterator();
       while (products.hasNext()) {
           Product product = products.next();
           if (isSatisfiedBy(price, product))
               foundProducts.add(product);
       }
       return foundProducts;
   }

   private boolean isSatisfiedBy(float price, Product product) {
       return product.getPrice() < price;
   }
}

Gdy przyjrzymy się bliżej wyodrębnionej metodzie widać, że wywołanie metody byBelowPrice pociąga za sobą wywołanie metody isSatisfiedBy. Ale w przypadku 1000 produktów metoda isSatisfiedBy jest wywołana 1000 razy z tą samą wartością pierwszego parametru (cena) i inną wartością drugiego parametru (Produkt).

To spostrzeżenie kieruje naszą uwagę na pomysł utworzenia nowej klasy BelowPriceSpec, która przechowuje pole, w którym przechowywana jest cena (zawsze taka sama), i zawiera metodę isSatisfiedBy, która przyjmuje pojedynczy parametr Produkt (zawsze inny).

*** Poziom klas ***

public class BelowPriceSpec {
   private float price;

   public BelowPriceSpec(float price) {
       this.price = price;
   }

   public float getPrice() {
       return price;
   }
}

public class ProductFinder {
   ...
   public List<Product> byBelowPrice(float price) {
       BelowPriceSpec belowPriceSpec = new BelowPriceSpec(price);

       List<Product> foundProducts = new ArrayList<>();
       Iterator<Product> products = repository.iterator();
       while (products.hasNext()) {
           Product product = products.next();
           if (product.getPrice() < belowPriceSpec.getPrice())
               foundProducts.add(product);
       }
       return foundProducts;
   }
}

Zwróćmy uwagę, że obecnie warunek jest sprawdzany poprzez pobranie ceny z pola w klasie („belowPriceSpec.getPrice ()”) zamiast pobrania/użycia ceny dostępnej bezpośrednio z parametru metody. W ten sposób, gdy ponownie wyodrębnię metodę isSatisfiedBy, to jej pierwszy parametr jest obiektem BelowPriceSpec, co pozwala mi przenieść tę metodę do klasy BelowPriceSpec.

public class ProductFinder {
       …
       public List<Product> byBelowPrice(float price) {
           BelowPriceSpec belowPriceSpec = new BelowPriceSpec(price);

           List<Product> foundProducts = new ArrayList<>();
           Iterator<Product> products = repository.iterator();
           while (products.hasNext()) {
               Product product = products.next();
               if (isSatisfiedBy(belowPriceSpec, product))
                   foundProducts.add(product);
           }
           return foundProducts;
       }

   private boolean isSatisfiedBy(BelowPriceSpec belowPriceSpec, Product product) {
       return product.getPrice() < belowPriceSpec.getPrice();
   }
}

*** Poziom wzorców / abstrakcji  ***

Oto jak wygląda klasa BelowPriceSpec po przeniesieniu metody isSatisfiedBy do niej i pozbyciu – używanego teraz tylko w jej wnętrzu – gettera (getPrice).

public class BelowPriceSpec {
   private float price;

   public BelowPriceSpec(float price) {
       this.price = price;
   }

   public oolean isSatisfiedBy(Product product) {
       return product.getPrice() < price;
   }
}

Dotychczasowe transformacje pokazują, że podążając za nimi zakończymy z wieloma takimi klasami, jak DeliverySpec, SizeSpec, MinAgeSpec (kiedy produkt jest zabawką z ograniczeniem wiekowym) i tak dalej. Klasy te zostaną będą dzielić wspólny interfejsy Spec, który czas wyodrębnić.

public interface Spec {
   boolean isSatisfiedBy(Product product);
}

public class BelowPriceSpec implements Spec {
   private float price;

   public BelowPriceSpec(float price) {
       this.price = price;
   }

   @Override
   public boolean isSatisfiedBy(Product product) {
       return product.getPrice() < price;
   }
}

public class ProductFinder {
   private List<Product> repository;

   public ProductFinder(List<Product> repository) {
       this.repository = repository;
   }

   public List<Product> byBelowPrice(float price) {
       Spec spec = new BelowPriceSpec(price);

       List<Product> foundProducts = new ArrayList<>();
       Iterator<Product> products = repository.iterator();
       while (products.hasNext()) {
           Product product = products.next();
           if (spec.isSatisfiedBy(product))
               foundProducts.add(product);
       }
       return foundProducts;
   }
}

*** Powrót do warstwy przepływu ***

W tym momencie mamy już wyodrębniony interfejs Spec. Ponadto większość metody “byBelowPrice” korzysta z interfejsu Spec, a nie z implementacji tego interfejsu (tj. szczegółów sprawdzania ceny). Możemy więc wyodrębnić część metody, która będzie wspólna dla podobnych metod (takich jak byMinAge, bySize) i w ten sposób wyodrębnić zupełnie nową metodę bySpec opartą wyłacznie na parametrze którym jest interfejs Spec.

public class ProductFinder {
   private List<Product> repository;

   public ProductFinder(List<Product> repository) {
       this.repository = repository;
   }

   private List<Product> bySpec(Spec spec) {
       List<Product> foundProducts = new ArrayList<>();
       Iterator<Product> products = repository.iterator();
       while (products.hasNext()) {
           Product product = products.next();
           if (spec.isSatisfiedBy(product))
               foundProducts.add(product);
       }
       return foundProducts;
   }

   public List<Product> byBelowPrice(float price) {
       Spec spec = new BelowPriceSpec(price);

       return bySpec(spec);
   }
}

*** Warstwa przepływu ***

Teraz przyjrzymy się bliżej metodzie “bySpec”. Wykorzystuje bardzo stare konstrukcje pochodzące z nawet sprzed Javy w wersji 1.5. Zamieńmy więc pętlę na strumienie. Martin Fowler w swojej ostatniej książce umieścił dodatkowe refaktoryzację o nazwie „Zastąp pętlę strumieniem” którą teraz możemy wykonać.

public class ProductFinder {
   private List<Product> repository;

   public ProductFinder(List<Product> repository) {
       this.repository = repository;
   }

   public List<Product> bySpec(Spec spec) {
       return repository.stream()
               .filter(spec::isSatisfiedBy)
               .collect(Collectors.toList());
   }

   @Deprecated
   public List<Product> byBelowPrice(float price) {
       return bySpec(new BelowPriceSpec(price));
   }
}

Pierwsza metoda teraz będzie brała argument Spec czyli interfejs i prawdopodobnie nie będzie już zmieniana. Druga metoda byBelowPrice – nawet jeżeli musi na razie zostać – nie wymaga już lokalnej zmiennej „spec” dla jej czytelności, więc została ona wchłonięta w miejscu jej użycia. Druga metoda dodatkowo korzysta teraz z logiki w pierwszej metodzie unikając w ten sposób duplikacji kodu.

*** Poziom architektury ***

Wreszcie możemy pomyśleć o ulepszeniu architektury. Obecny artykuł rośnie, więc postanowiłem wyjaśnić poziom Architektury jako część następnego artykułu. „Piramida refaktoryzacji jest SOLIDNA”.

Spread the word. Share this post!