25.10.20226 min

Cezary ChudzikJava DeveloperAsseco Poland S.A.

Czysty kod w praktycznych rozwiązaniach

W tym artykule poznasz podstawowe zasady w pisaniu czystego kodu. Jedziemyyy!

Czysty kod w praktycznych rozwiązaniach

Każdy z nas pracował nad projektami. W początkowej fazie projektu dodawanie nowych funkcjonalności, czy wprowadzanie poprawek idzie bardzo szybko. Problemy pojawiają się z czasem, gdy po napisaniu kodu nie poświęcimy dodatkowego czasu na poprawę czytelności kodu. Wiedzą o tym w szczególności osoby, które musiały wprowadzać zmiany w starych projektach, cudzym kodzie, a nawet we własnym następnego dnia :)

Wykres kosztów od czasu


Nazewnictwo

Nazwa zmiennej powinna przedstawiać intencje programisty. Dla osoby obcej powinno być jasne i oczywiste, po co zmienna została wprowadzona. Nie skracajmy nazw obiektów opisujących model. Dla przykładu mając obiekt modelujący użytkownika, nie przypisujemy mu skróconej nazwy u, użyjmy pełnej nazwy user. Dla nas może być to jasne, ale dla osoby chcącej wprowadzić nazwy w drobnym fragmencie już nie.

User u = new User();   ->     User user = new User();


Unikajmy nazw zbyt długich, podobnych w wymowie do innych, oraz synonimów. Czytając kod problemem, będzie zorientowanie się, czym różni się customer od buyer.

Customer customer = new Customer();    ?     Buyer buyer = new Buyer();


Nie stosujmy skrótów. Wiele skrótów jest powszechnie znane wszystkim, inne nie. Już nie mówiąc o skracaniu nazw userDateOfBirth na nic nie mówiące userDOF.

private Date userDOF;   -> private Date userDayOdBirth;


Kończąc nazewnictwo, przypomnę jeszcze by nazywać klasy i obiekty od rzeczowników, jako iż mają zazwyczaj swoją fizyczną reprezentację, zaś metody jako że wykonują czynności od czasowników.

Nie możemy zostawiać miejsca na niejednoznaczności, bądź niedomówienia w nazwach.


Metody do sprawdzania stanu

Dodawanie metod sprawdzających stan obiektu, jest bardziej intuicyjne niż sprawdzanie pól. Dla przykładu gdy chcemy sprawdzić poprawność wypełnionego wniosku, czytelniej będzie dodać metodę form.isSent(), niż sprawdzać pola form.getSentDate != null.

if(form.getSentDate != null)  ->  if(form.isSent())


Przykłady praktyczne

Wrapowanie warunków logicznych.

Przyjmijmy, że mamy klasę User:

public class User {
   private Long id;
   private LocalDate registrationDate;
   private LocalDate lastLoginDate;
   private List<Role> roles;
   private Boolean isActive;
   ...


Enum:

public class User {
   private Long id;
   private LocalDate registrationDate;
   private LocalDate lastLoginDate;
   private List<Role> roles;
   private Boolean isActive;
   ...


I poniższy kod, który chcemy poprawić:

   private void sendPremiumPeriodAvailableNotificationDirty(User user) {
       if (user.getRegistrationDate().isAfter(LocalDate.now().minusYears(2)) &&
               (!user.getActive() ||
                       (user.getRoles().contains(Role.GUEST) ||
                               (user.getRoles().contains(Role.USER)
                                       && user.getLastLoginDate().isAfter(LocalDate.now().minusMonths(2)))))) {
//            sendNotification();
       }


Kod nie jest skomplikowany. Chcemy wysłać powiadomienia do użytkowników spełniających pewne kryteria. Mimo prostoty kodu zajmie nam chwilę zrozumienie działania kodu, a przy odrobinie wysiłku, moglibyśmy go przerobić na kod jasny przy pierwszym spojrzeniu.

Zaczynając od warunku:

user.getRegistrationDate().isAfter(LocalDate.now().minusYears(2)​


Przypiszmy go do samo opisującej się zmiennej:

var registrationDateLongerThen2Years = user.getRegistrationDate().isAfter(LocalDate.now().minusYears(2));


idąc dalej warunek opisujący użytkownika, który zalogował się ostatni raz dwa lata temu:

user.getRoles().contains(Role.USER) && user.getLastLoginDate().isAfter(LocalDate.now().minusMonths(2));


Możemy owrapować jasnym opisem:

var userLastLogin2YearsAgo = user.getRoles().contains(Role.USER) && user.getLastLoginDate().isAfter(LocalDate.now().minusMonths(2));


Analogicznie możemy połączyć dwa powyższe warunki w:

var hasPermissionToFreePremiumPeriod = !user.getActive() || userLastLogin2YearsAgo;


Otrzymując docelową metodę:

   private void sendPremiumPeriodAvailableNotificationClean(User user) {
       var registrationDateLongerThen2Years = user.getRegistrationDate().isAfter(LocalDate.now().minusYears(2));
       var userLastLogin2YearsAgo = user.getRoles().contains(Role.GUEST) || (user.getRoles().contains(Role.USER) && user.getLastLoginDate().isAfter(LocalDate.now().minusMonths(2)));
       var hasPermissionToFreePremiumPeriod = !user.getActive() || userLastLogin2YearsAgo;
       var sendNotification = registrationDateLongerThen2Years && hasPermissionToFreePremiumPeriod;
 
       if (sendNotification) {
//            sendNotification();
       }
   }

Powyższą metodę możemy przeczytać bez problemu stosując język ludzki.

Jeżeli wyślij powiadomienie jest true, wyślij powiadomienie. wyślij powiadomienie jeżeli data rejestracji większa niż dwa lata, oraz użytkownik ma do niego prawo.

Owrapowywanie nawet najmniejszych funkcji, znacząco zwiększa czytelność:

private void removeUserDirty(User user) {
   if (!user.getActive() && user.getLastLoginDate().isAfter(LocalDate.now().minusMonths(2)) && user.getRoles().contains(Role.GUEST)) {
       //removeUser()
   }
}
 
private void removeUserClean(User user) {
   var canRemoveUser = !user.getActive() && user.getLastLoginDate().isAfter(LocalDate.now().minusMonths(2)) && user.getRoles().contains(Role.GUEST);
   if (canRemoveUser) {
       //removeUser()
   }
}


W większości przypadków nie będzie nas interesował sam warunek. Od razu zorientujemy się nad logiką warunku, i przejdziemy do dalszej analizy kodu.


Podwójne przeczenia
 

Mamy kod:

Boolean isNotBlocked = false;

 
private void doSomething (Boolean isNotBlocked) {

   if(!isNotBlocked) {

       //changeState()

   }


Prawdopodobieństwo późniejszych problemów wynikających z podwójnego przeczenia jest ogromne. Czytanie powyższego warunku: “nie jest nie jest zablokowane” jest całkowicie nieintuicyjne. Przy bardziej złożonym kodzie, gdzie mamy myśli zapchane innymi warunkami łatwo się pomylić.

Boolean isBlocked = true;

private void doSomething2 (Boolean isNotBlocked) {
   if(isBlocked) {
       //changeState()
   }
}


Bardzo drobna zmiana, a bardzo istotna dla ułatwienia czytania kodu.


Unikanie else

Ten punkt może być kontrowersyjny, jednakże usuwając else będziemy w stanie uprościć poniższy kod:

public void canDrinkDirty(Person person) {
   if(person.getAge() != null) {
       if(person.getAge() < 15) {
           log.info("No");
       } else if(person.getAge() < 18) {
           log.info("Yes in Germany");
       } else {
           log.info("Yes");
       }
   } else {
       log.info("Person age is null");
   }
}


Sam kod nie jest zły, ale mógłby być czytelniejszy.

W powyższym przykładzie mamy krótkiego else, aż się prosi by uprościć stosując “strażnika”.

if(person.getAge() == null) {
   return "Person age is null";
}
...


Tym sposobem wyeliminujemy pierwszego elsa.

Dalsze eliminowanie elsow możemy przeprowadzić analogicznie stosując zmienną pomocniczą:

var result = "Yes"
if(person.getAge() < 15) { result = "No"}
if(person.getAge() < 18) { result = "Yes in Germany"}
return result;


Jednakże takie wyjście nie jest do końca poprawne. Często spotykamy się z polityką jednego return dla metody, ale czy to czyni nasz kod lepszym? Wymusza nam to skomplikowany, zagnieżdżony. Przenieśmy powyższy kod do osobnej metody:

private String canDrinkResponse(Integer age) {
   if((age < 15) ) {return "No";}
   if((age < 18) ) {return "Yes in Germany";}
   return "Yes";


Powyższe operacje dadzą nam kod:

public String canDrinkClean(Person person) {

   if(person.getAge() == null) {return "Person age is null";}
   return canDrinkResponse(person.getAge());
}

private String canDrinkResponse(Integer age) {
   if((age < 15) ) {return "No";}
   if((age < 18) ) {return "Yes in Germany";}
   return "Yes";


Taki kod będzie w stanie zrozumieć nawet osoba mająca nikłe pojęcie o programowaniu.


Zastosowanie continue

public void doSomethingDirty(State[][] matrix,boolean[][] visitedMatrix ) {
   int countVisited = 0;
   for (int i = 0; i < matrix.length; i++) {
       for (int j = 0; j < matrix[i].length; j++) {
           if(!visitedMatrix[i][j])

               var value = matrix[i][j];
               if(!value.equals(State.CLOSED)) {
                   //LOGIKA
                   //LOGIKA
                   //LOGIKA
                   //LOGIKA
                   //LOGIKA
               } else {
                   countVisited = countVisited++;
               }
           }
       }
   }


W tym wypadku logika w else jest niewielka. Za pomocą continue możemy pozbyć się elsów upraszczając kod.

public void doSomethingClean(State[][] matrix, boolean[][] visitedMatrix) {
   int countVisited = 0;
   for (int i = 0; i < matrix.length; i++) {
       for (int j = 0; j < matrix[i].length; j++) {
           if (visitedMatrix[i][j]) {
               continue;
           }


           var value = matrix[i][j];
           if (value.equals(State.CLOSED)) {
               countVisited = countVisited++;
               continue;
           }

           //LOGIKA
           //LOGIKA
           //LOGIKA
           //LOGIKA
           //LOGIKA
       }
   }


Po zmianach mamy wydzielone warunki dokonujące drobnych logicznych operacji. Przy przyszłych zmianach (a pewnie się pojawią), edycja takiego kodu będzie przyjemnością ;)


Rozbijanie długich funkcji

private void updateAdresDirty (Long customerId, Address address) {

       Optional<Customer> customer = this.customerRepository.getCustomerByID(customerId);
       if (!customer.isPresent()) {
           throw new NotFoundException();
       }

       if(customer.get().getLastAdresUpdate().isBefore(address.getDateOfChange())) {
           customer.get().setFullAdres(address.getFullAdres());
           customer.get().setLastAdresUpdate(address.getDateOfChange());
       } else {
           address.setFullAdres(customer.get().fullAdres);
           address.setDateOfChange(customer.get().getLastAdresUpdate());
       }


Dana metoda pobiera z bazy użytkownika, a następnie aktualizuje użytkownika w bazie bądź adres w pamięci. Zamiast pisać komentarze, rozbicie na podfunkcję ułatwi nam czytanie kodu.

Możemy zacząć od przeanalizowania kodu i spisaniu co on robi. 1) pobiera użytkownika.

  • 2.1) aktualizuje użytkownika;
  • 2.2) aktualizuje adres;

Mając powyższą listę, możemy przepisać to na kod samoopisujący się:

Poprawiając kod, nie potrzebujemy przeglądać całego kodu, nie dość, że kod jest czytelny w języku ludzkim, to poprawki w metodzie updateAddressClean nie wymuszają u nas przeglądania całego kodu.

Dodatkową zaletą rozbijania funkcji jest ułatwienie w testowaniu. Łatwiej nam będzie napisać testy dla małych funkcji.


Podsumowanie

W dobie ciągle rosnących złożoności projektów, ważne jest utrzymywanie jakości kodu. Mimo nacisku przełożonych na prędkość  wydawania zmian/poprawek, to w naszym interesie i moralnym obowiązku jest oddanie kodu, który przejęty przez następnego programistę nie przysporzy mu problemów. Zmuszajmy się po wyprodukowaniu działającego kodu, do poświęcenia paru minut i poprawieniu jego czytelności! Piszmy taki kod, jaki sami chcielibyśmy otrzymać!

<p>Loading...</p>