fbpx

W tym poście chciałbym pokazać piramidę refaktoryzacji w praktyce czyszczenia kodu. 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. 

W poprzednim artykule (Jak zauważyłem Piramidę Refaktoryzacji) 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ż są one często zautomatyzowane. Jeżeli rozsądnie korzystamy z takich transformacji, to logika naszego kodu powinna nadal działać tak, jak działała wcześniej.

Nieliczne wyjątki od tej reguły to transformacje typu “ekstrakcja pola”. Kiedy metoda ustawia pole zamiast używać zmiennych lokalnych, możemy napotkać problemy z wielowątkowością. Kiedy drugi wątek uruchomi tą samą metodę w tym samym czasie wóczas będą współdzielić dane pole.

Dla przedstawienia koncepcji wykorzystam refaktoryzacją do wzorca Interpreter. Po zapoznaniu się z Piramidą Refaktoryzacji zauważsz, że jest ona obecna większości przykładów z wymienionej książki. W książce nie nie została ona jednak w ten sposób nazwana ani zauważona.

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 sprawdzenie , 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. Zawsze z tą samą wartością pierwszego parametru (cena) i inną wartością drugiego parametru (Produkt).

To spostrzeżenie kieruje moją uwagę na pomysł utworzenia nowej klasy BelowPriceSpec, która przechowuje pole, zawierające cenę (zawsze taka sama). Klasa będzie zawierać także 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. To już 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();
   }
}

[FM_form id=”2″]

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. Będą to np. DeliverySpec, SizeSpec, MinAgeSpec (kiedy produkt jest zabawką z ograniczeniem wiekowym). 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 jeszcze wyodrębnić część metody, która będzie wspólna dla podobnych metod (takich jak byMinAge, bySize). Wyodrębnimy więc kolejną 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. Jak zasady SOLID wspierają refaktoryzację? .

Spread the word. Share this post!