Słowo kalistenika to połączenie dwóch słów wywodzących się ze starożytnej Grecji: kállos (piękno) i sthénos (siła). W kontekście aktywności fizycznej odnosi się to do pracy nad mięśniami bez żadnego dodatkowego sprzętu czy obciążenia. Chodzi o opieranie się na ćwiczeniach tylko z własną masą ciała. Jak jednak ma się ten wstęp do programowania?

Niedawno oglądałem wystąpienie Mario Fusco na wrocławskim JUGu. Dotyczył on projektowania API w Javie. Jednak nie to było istotne z punktu widzenia dzisiejszego artykułu. Podczas sekcji Q&A padło pytanie dotyczące właśnie tytułowego Object Calisthenics. Zainteresowało mnie to zagadnienie, więc postanowiłem co nieco o nim poczytać. Muszę przyznać, że zasady przedstawione w Object Calisthenics mogą być naprawdę pomocne przy codziennej pracy. Stąd właśnie chęć do podzielenia się nimi z Wami.

Skąd wzięła się idea Object Calisthenics?

Sam koncept Object Calisthenics wymyślił Jeff Bay. Rozpowszechnił go on poprzez książkę “The ThoughtWorks Anthology” (ThoughWorks to firma konsultingowa znana na całym świecie). Jeff Bay zaproponował w niej 9 zasad, które mają za zadanie poprawić sylwetkę naszej aplikacji.

Słowo Object w nazwie ma oznaczać fakt, że dotyczą one programowania obiektowego. Natomiast Calisthenics, jak wspomniałem na wstępie, wskazuje na ćwiczenia, które mamy wykonywać, aby nasza aplikacja była bardziej utrzymywalna, czytelna, testowalna i zrozumiała. Podążanie za poniżej przedstawionymi zasadami ma sprawić, że zmienimy podejście do pisania kodu. Oczywiście na lepsze.

  1. Only One Level Of Indentation Per Method
  2. Don’t Use The ELSE Keyword
  3. Wrap All Primitives And Strings
  4. First Class Collections
  5. One Dot Per Line
  6. Don’t Abbreviate
  7. Keep All Entities Small
  8. No Classes With More Than Two Instance Variables
  9. No Getters/Setters/Properties

Tylko jeden poziom wcięć na metodę (Only One Level Of Indentation Per Method)

Nie wiem jak Tobie, ale mi ciężko się połapać w kodzie, który ma tzw. “choinkę ifów”. Jest w nim tyle zagnieżdzeń, że nie wiadomo czy jakieś warunki się nie wykluczają. Tutaj na pomoc przychodzi zasada Only One Level Of Indentation Per Method, którą dobrze jest przedstawić przy pomocy przykładu.

1
2
3
4
5
6
7
8
9
10
11
12
13
if (client.isVip()) {
  if (order.isPaid()) {
    System.out.println("vip paid for order");
  } else {
    System.out.println("vip did not pay for order");
  }
} else {
  if (order.isPaid()) {
    System.out.println("regular paid for order");
  } else {
    System.out.println("regular did not pay for order");
  }
}

Nie jest to najlepszy kawałek kodu, ale nie w tym rzeczy. Warto się w nim skupić na tym, że posiada on dwa poziomy wcięć. Próbując naprawić kod według powyższej zasady należałoby to wykonać na przykład w następujący sposób.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private static void handleLoggerFor(Client client, Order order) {
  if (client.isVip()) {
  handleOrderLoggerForVip(order);
  } else {
  handleOrderLoggerForRegular(order);
  }
}

private static void handleOrderLoggerForVip(Order order) {
  if (order.isPaid()) {
    System.out.println("vip paid for order");
  } else {
    System.out.println("vip did not pay for order");
  }
}

private static void handleOrderLoggerForRegular(Order order) {
  if (order.isPaid()) {
    System.out.println("regular paid for order");
  } else {
    System.out.println("regular did not pay for order");
  }
}

Być może ta porada ma zastosowanie jako dobra heurystyka na zasadzie metody kciuka. Mnie natomiast jakoś nie przekonuje. Ktoś może przyjąć ją za świętość i na review zwracać na to uwagę bez chwili zastanowienia czy to faktycznie jest najpoważniejszy problem. Czy o to właśnie chodzi w tym procesie?

Wydaje mi się, że robiąc w ten sposób, tak mechanicznie, możemy pominąć coś istotnego z punktu biznesowego. Czasami jednak może nam to trochę pomóc w tym, aby lepiej nazwać daną operację. Wtedy faktycznie nasza lampka się zapali, że dana rzecz nie powinna należeć do tego miejsca w kodzie. Jednak trzymanie się kurczowo takich zmian w większości przypadków tylko nałoży piękną szpachlę na prawdziwy problem, który zostanie ukryty głębiej. Stąd moja rekomendacja, aby do tej zasady podchodzić z przymrużeniem oka.

Nie korzystaj ze słowa kluczowego ELSE (Don’t Use The ELSE Keyword)

else jak wiemy należy do konstrukcji if/else. Pomaga nam wykonać kawałek kodu, gdy nie spełnimy danego warunku instrukcji warunkowej.

1
2
3
4
5
6
if (client != null) {
  messageSender.sendPositiveMessage(client.getId());
} else {
  Client createdClient = createClient();
  messageSender.sendConfirmationMessage(createdClient.getId());
}

Czytanie takiego kodu nie jest uciążliwe, ale nie jest też najprzyjemniejsze. Można go sobie uprościć wyrzucając niepotrzebne zagnieżdżenie w postaci else. Trzeba spełnić tylko jeden warunek.

1
2
3
4
5
6
if (client != null) {
  messageSender.sendPositiveMessage(client.getId());
  return;
}
Client createdClient = createClient();
messageSender.sendConfirmationMessage(createdClient.getId());

Trzeba zakończyć wykonywanie metody, gdy wejdzie ona w wykonywanie ifa. Jednym ze sposobów jest powyższy kodzik. Zachowanie jest identyczne, a my pozbyliśmy się jednego z zagnieżdżeń. Można pójść o krok dalej. Zamiast jak tutaj zwracać void możemy przekazać jakąś wartość.

1
2
3
4
5
if (client != null) {
  return messageSender.sendPositiveMessage(client.getId());
}
Client createdClient = createClient();
return messageSender.sendConfirmationMessage(createdClient.getId());

Wtedy jeszcze ładniej to wszystko wygląda. Dodatkowo pisanie testów tej metody będzie łatwiejsze, bo mamy teraz sytuację input/output. Znowu, świata tą zasadą nie zwojujemy, ale jak dla mnie jest ona o wiele przydatniejsza niż pierwsza z Object Calisthenics.

Można to również wykorzystać przy zasadzie Fail Fast, o której kiedyś wspominałem. Zamiast pisać coś takiego.

1
2
3
4
5
6
7
if (clientName == null) {
  throw new IllegalArgumentException("name cannot be null");
} else if (clientName.isEmpty()) {
  throw new IllegalArgumentException("name cannot be empty");
} else {
  return new Client(clientName);
}

Możemy rozbić to na dwa ify, które pokazują nam od razu co sprawdzają. Takie podejście przydaje się do tworzenia Value Objects w prawidłowym stanie.

1
2
3
4
5
6
7
if (clientName == null) {
  throw new IllegalArgumentException();
}
if (clientName.isEmpty()) {
  throw new IllegalArgumentException("name cannot be empty");
}
return new Client(clientName);

Opakowuj typy prymitywne - Wrap All Primitives And Strings

Na tej zasadzie naprawdę warto się skupić. Można powiedzieć, że jest to filar kreowania Value Objects. Korzystanie tylko z int, double, String itd. to przedwczesna optymalizacja, której należy się wystrzegać jeśli nie mamy ku temu żadnych pobudek wydajnościowych. Zaciemnia nam tylko obraz tego jak przekazywać dane do naszych metod czy konstruktorów. W ten sposób możemy przez przypadek np. zamienić komuś imię i nazwisko (może mieć to znaczenie w przypadku osób, które mają nazwiska pochodzące od imion). Dlatego warto tworzyć małe klasy, które nadają nam KONTEKST tego co dana wartość ze sobą niesie.

Klasa agregująca kolekcje - First Class Collections

Gdy zapoznawałem się z koncepcją Object Calisthenics to ta zasada była dla mnie czymś nowym, otwierającym oczy. Dlaczego co chwila mam filtrować gdzieś dane, aby wyciągnąć z kolekcji klientów VIPów? Mogę przecież owrapować tą kolekcję i schować to filtrowanie w odpowiedniej metodzie. Ostatnio zastosowałem to podejście w projekcie, nad którym pracuję. Kod stał się faktycznie czytelniejszy, więc ta zasada jest jak najbardziej na plus.

1
2
3
4
5
6
7
8
9
10
11
12
13
//...
import org.springframework.data.repository.CrudRepository;
//...

public interface ClientRepository extends CrudRepository<Client, Long> {

  Set<Client> findAllByAggregateId(@NonNull AggregateId aggregateId);

  default Clients findClientsByAggregateId(@NonNull AggregateId aggregateId) {
    return Clients.of(findAllByPolicyId(policyId));
  }

}

Akurat jest to przykład ze Springa Data. Wykorzystuję metodę do znajdowania kolekcji klientów tylko po to, aby je opakować w klasę Clients. Teraz to ona hermetyzuje to co się dzieje z naszym zbiorem. Na razie testuję to rozwiązanie, ale na ten moment się ono sprawdza.

Jedna “kropka” na linijkę - One Dot Per Line

Klasyczna podstawa dla prawa Demeter. Oczywiście nie można za nią ślepo podążać, bo w ten sposób możemy sami siebie oszukać. Tutaj znowu mamy doczynienia z mechaniczną zasadą, nad którą lepiej się chwilę zastanowić. Nie chodzi o to, aby client.getOrder().getName() zamienić na Order order = client.getOrder() i OrderName name = order.getName(). Trzeba bardziej przemyśleć własny model i nie udostępniać tego co mamy w obiekcie na zewnątrz. Więcej o tym pisałem w artykule o prawie Demeter.

Nie używaj skrótów - Don’t Abbreviate

Prosta zasada. Nie używaj skrótów. Nadawaj zmiennym KONTEKST. Zamiast korzystać z nazw a czy desc to pisz całe słowa - client, description. Poświęcisz na to kilka uderzeń w klawiaturę więcej, a uprościsz znacznie czytanie Twojego kodu innym. Natomiast można przy okazji zastanowić się nad odwrotną sytacją. Co w przypadku, gdy jedyną pasującą nazwą jest clientWithTwentyOverduePayments dla naszej zmiennej? Według mnie wtedy warto się zastanowić nad zmianą modelu. Być może klient mógłby stać się czymś innym po przekroczeniu jakiegoś limitu np. Client -> ClientWithOverdues. Jaki jest Twój pomysł na ten przypadek?

Twórz małe encje - Keep All Entities Small

Autor Object Calisthenics troszczy się o nasze przeciążenie poznawcze. Tą zasadą zachęca nas, aby ustalić sobie jakiś limit linijek per klasa albo klas w pakiecie. Bo jak wiemy im więcej kodu w jednym miejscu tym trudniej jest się w nim połapać. Oczywiście jak dobrze odrobimy lekcje na wyższym poziomie to ta zasada nam się sama na pewno automatycznie spełni. Natomiast, jak już wspomniałem wcześniej, nie warto podążać ślepo za każdą radą. Czasem może warto celowo jakąś złamać i paradoksalnie sprawić, że kod będzie czytelniejszy. Dlatego i w tym miejscu przymrużyłbym oko i na tą poradę. Jednocześnie nie popadając w skrajność i nie tworząc ośmiotysięczników.

Maksymalnie dwa pola na klasę - No Classes With More Than Two Instance Variables

Tutaj to jest już jazda bez trzymanki. Dwa pola na jedną klasę?! Ciekawa heurystyka, która ma nam pomóc zapewnić wysoką spojność naszych klas. Znowu, na pewno nie będą nas bolały dwie, trzy, cztery czy nawet pięć zależności jeśli dobrze przeanalizujemy nasz model z “lotu ptaka”. Dlatego ta zasada może posłużyć jako drogowskaz dla juniorów, ale też raczej bym poluzował to ograniczenie do np. czterech zależności. Zdecydowanie, porada No Classes With More Than Two Instance Variables należy do tych najmniej pomocnych.

Bez getterów i setterów - No Getters/Setters/Properties

Jak nic to jest podstawa programowania obiektowego. Zaprojektuj swojej klasie zachowania manipujące danymi zamiast udostępniać je innym do modyfikacji. Nic dodać, nic ująć. Według mnie to ta zasada powinna się znajdować na samej górze.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Enemy {
  
  int hp;
  boolean killed;

  int getHp() {
    return hp;
  }

  void setHp(int hp) {
    this.hp = hp;
  }

  boolean isKilled() {
    return killed;
  }

  void setKilled(boolean killed) {
    this.killed = killed;
  }

}
1
2
3
4
5
6
Enemy enemy = new Enemy();
int newHp = enemy.getHp() - damage;
enemy.setHp(newHp);
if (newHp < 0) {
  enemy.setKilled(true);
}

Mamy tutaj klasyczny przypadek traktowania klasy jako struktury danych. Kod do wyciągania danych i ich ustawiania na nowo pewnie znalazłby się w jakimś serwisie. Niby spoko, ale gdy ta logika będzie niezbędna w kompletnie innym miejscu pewnie zostałaby powtórzona. Lepiej jest, więc ukryć zachowanie za stabilnym interfejsem w postaci dedykowanej metody.

1
2
3
4
enemy.receiveDamage(damage);
if (enemy.isKilled()) {
  //...
}

Teraz nie musimy wiedzieć czy isKilled operuje na boolean czy też inaczej wylicza to, że nasz wróg wypadł z gry. Nie interesuje nas to. Mamy API, z którego musimy skorzystać. Jest czytelne i można się domyślić po nazwie do czego ono służy.

Podsumowanie

Tak prezentuje się koncepcja Object Calisthenics. Gdybym potrzebował wskazówek jako początkujący to na pewno byłyby one dla mnie świetnym drogowskazem. Natomiast niektóre zasady mogą tylko ograniczać programistów na wyższym poziomie wtajemniczenia. To w sumie dobrze zostało opisane w modelu rozwoju kompetencji braci Dreyfus. Niemniej jednak jest tutaj parę porad, na które warto zwrócić uwagę. Mogą one nam przypomnieć o czymś o czym zapomnieliśmy poprzez ciągłe bycie na placu boju. Dlatego mam nadzieję, że ten artykuł przyczyni się do odświeżenia sobie pamięci.