Refaktoryzacja kodu

Omówienie

Refaktoryzacja (refaktoring) jest techniką wykorzystywaną w programowaniu, która polega na przekształceniu już istniejącego kodu do bardziej czytelnej i łatwiejszej w utrzymaniu postaci. W skrócie jest to posprzątanie bałaganu w swoim kodzie. Refaktoryzacja odgrywa szczególną rolę w aplikacjach, które są zaniedbane i wprowadzanie nowych funkcjonalności powoduje duże problemy. W wykonywaniu refaktoryzacji można wykorzystać różne narzędzia, szczególnie środowisko programistyczne takie jak IntelliJ IDEA, czy Eclipse, a także różne techniki i wzorce związane z czystym kodem.

Przyczyną, która prowadzi do tego, że refaktoryzacja jest w ogóle konieczna, jest najczęściej duży dług techniczny (ang. technical debt). Dług techniczny polega na tym, że zamiast stosować w swoim kodzie sprawdzone techniki i wzorce, to zamiast tego często idziemy na skróty z myślą "kiedyś to poprawię". Refaktor ze swojej natury nie zmienia funkcjonalności programu, tylko powoduje uporządkowanie kodu.

Objawami tego, że warto wykonać refaktoring są takie sygnały jak:

  • powtarzanie po raz kolejny tego samego lub bardzo podobnego kodu,
  • bardzo długie klasy lub metody,
  • występowanie kodu, który nie jest używany (dead code),
  • występowanie dużej liczby komentarzy, które tłumaczą co się dzieje w kodzie.

Kurs Java

Przykład

W przypadku języka Java dosyć często można spotkać się z sytuacją, w której programista tworzy rozbudowane metody, które można podzielić na mniejsze fragmenty. Szczególnie początkujący mają często problem z prawidłowym stosowaniem paradygmatu obiektowego i zapisują kod w sposób strukturalny - bez podziału na odpowiednie klasy i metody. Poniżej przykład takiego kodu:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

class NumbersOperations {
    public static void main(String[] args) {
        Scanner s = new Scanner(System.in);
        List<Integer> list = new ArrayList<>();
        System.out.println("Ile liczb wczytać?");
        int x = s.nextInt();
        for (int i = 0; i < x; i++) {
            System.out.println("Podaj kolejną liczbę:");
            list.add(s.nextInt());
        }
        List<Integer> pos = new ArrayList<>();
        for (int i = 0; i < list.size(); i++) {
            if (list.get(i) >= 0)
                pos.add(list.get(i));
        }
        List<Integer> neg = new ArrayList<>();
        for (int i = 0; i < list.size(); i++) {
            if (list.get(i) < 0)
                neg.add(list.get(i));
        }
        int spos = 0;
        for (int i = 0; i < pos.size(); i++) {
            spos += pos.get(i);
        }
        int sneg = 0;
        for (int i = 0; i < neg.size(); i++) {
            sneg += neg.get(i);
        }
        System.out.println("Suma wprowadzonych liczb dodatnich: " + spos);
        System.out.println("Suma wprowadzonych liczb ujemnych: " + neg);
    }
}

Program jest prosty, wczytuje od użytkownika liczby do listy. Następnie lista dzielona jest na dwie listy z liczbami dodatnimi i ujemnymi, później wykonujemy sumowanie jednych i drugich i wyświetlamy wynik na ekranie.

refactoring 1

Nazewnictwo zmiennych

Samo działanie programu jest poprawne, jednak można mieć bardzo duże zastrzeżenia co do jego czytelności. Gdyby nie komunikaty zapisane w instrukcjach System.out.println() to byłoby bardzo ciężko zrozumieć co się dzieje w kodzie. Powodem jest przede wszystkim używanie nazw zmiennych, które nic nie mówią. Nazwa "s" nie mówi kompletnie co jest przypisane do takiej zmiennej. Nazwa "list" jest nieco lepsza, ale po chwili łatwo zapomnieć, czy w liście tej są liczby, napisy, czy może jeszcze inne obiekty.

Pierwszy etap to zmiana nazw zmiennych. Pomocne jest tutaj wykorzystanie opcji zmiany nazw zmiennych w środowisku programistycznym. W IntelliJ IDEA kliknij na nazwę zmiennej, a następnie skrót klawiaturowy Shift + F6. W oknie, które się pojawi wpisz nową nazwę zmiennej. Dzięki temu zmianie ulegną wszystkie wystąpienia danej zmiennej, nie trzeba będzie trzeba poprawiać nazwy ręcznie we wszystkich miejscach, gdzie była używana.

Po wprowadzeniu zmian otrzymujemy taki kod, który wygląda już dużo lepiej:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

class NumbersOperations {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        List<Integer> userNumbers = new ArrayList<>();
        System.out.println("Ile liczb wczytać?");
        int numberOfNumbers = scanner.nextInt();
        for (int i = 0; i < numberOfNumbers; i++) {
            System.out.println("Podaj kolejną liczbę:");
            userNumbers.add(scanner.nextInt());
        }
        List<Integer> positiveNumbers = new ArrayList<>();
        for (int i = 0; i < userNumbers.size(); i++) {
            if (userNumbers.get(i) >= 0)
                positiveNumbers.add(userNumbers.get(i));
        }
        List<Integer> negativeNumbers = new ArrayList<>();
        for (int i = 0; i < userNumbers.size(); i++) {
            if (userNumbers.get(i) < 0)
                negativeNumbers.add(userNumbers.get(i));
        }
        int positiveNumbersSum = 0;
        for (int i = 0; i < positiveNumbers.size(); i++) {
            positiveNumbersSum += positiveNumbers.get(i);
        }
        int negativeNumbersSum = 0;
        for (int i = 0; i < negativeNumbers.size(); i++) {
            negativeNumbersSum += negativeNumbers.get(i);
        }
        System.out.println("Suma wprowadzonych liczb dodatnich: " + positiveNumbersSum);
        System.out.println("Suma wprowadzonych liczb ujemnych: " + negativeNumbersSum);
    }
}

Wydzielenie metod

Kolejny problem w przedstawionym programie to ilość kodu zapisanego w metodzie main. Kod powinien odzwierciedlać kolejne kroki algorytmu. W naszym przypadku ciężko jest wyróżnić poszczególne etapy działania programu, a powinniśmy tutaj jasno widzieć:

  • wczytanie liczb od użytkownika,
  • wydzielenie liczb dodatnich,
  • wydzielenie liczb ujemnych
  • obliczenie sumy liczb,
  • wyświetlenie wyników.

W celu wydzielenia fragmentu kodu do osobnej metody zaznacz wybrany fragment kodu, kliknij prawym przyciskiem myszy, a następnie wybierz opcję Refactor > Extract Method. Możesz też skorzystać ze skrótu klawiaturowego Alt + Enter lub Option + Enter.

refactoring extract methodPo wydzieleniu odpowiednich metod uzyskujemy taki efekt:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

class NumbersOperations {
    public static void main(String[] args) {
        List<Integer> userNumbers = getNumbersFromUser();
        List<Integer> positiveNumbers = getPositiveNumbers(userNumbers);
        List<Integer> negativeNumbers = getNegativeNumbers(userNumbers);
        int positiveNumbersSum = positiveNumbersSum(positiveNumbers);
        int negativeNumbersSum = negativeNumbersSum(negativeNumbers);
        printResults(positiveNumbersSum, negativeNumbersSum);
    }

    private static void printResults(int positiveNumbersSum, int negativeNumbersSum) {
        System.out.println("Suma wprowadzonych liczb dodatnich: " + positiveNumbersSum);
        System.out.println("Suma wprowadzonych liczb ujemnych: " + negativeNumbersSum);
    }

    private static int negativeNumbersSum(List<Integer> negativeNumbers) {
        int negativeNumbersSum = 0;
        for (int i = 0; i < negativeNumbers.size(); i++) {
            negativeNumbersSum += negativeNumbers.get(i);
        }
        return negativeNumbersSum;
    }

    private static int positiveNumbersSum(List<Integer> positiveNumbers) {
        int positiveNumbersSum = 0;
        for (int i = 0; i < positiveNumbers.size(); i++) {
            positiveNumbersSum += positiveNumbers.get(i);
        }
        return positiveNumbersSum;
    }

    private static List<Integer> getNegativeNumbers(List<Integer> userNumbers) {
        List<Integer> negativeNumbers = new ArrayList<>();
        for (int i = 0; i < userNumbers.size(); i++) {
            if (userNumbers.get(i) < 0)
                negativeNumbers.add(userNumbers.get(i));
        }
        return negativeNumbers;
    }

    private static List<Integer> getPositiveNumbers(List<Integer> userNumbers) {
        List<Integer> positiveNumbers = new ArrayList<>();
        for (int i = 0; i < userNumbers.size(); i++) {
            if (userNumbers.get(i) >= 0)
                positiveNumbers.add(userNumbers.get(i));
        }
        return positiveNumbers;
    }

    private static List<Integer> getNumbersFromUser() {
        Scanner scanner = new Scanner(System.in);
        List<Integer> userNumbers = new ArrayList<>();
        System.out.println("Ile liczb wczytać?");
        int numberOfNumbers = scanner.nextInt();
        for (int i = 0; i < numberOfNumbers; i++) {
            System.out.println("Podaj kolejną liczbę:");
            userNumbers.add(scanner.nextInt());
        }
        return userNumbers;
    }
}

Wygląda to zdecydowanie lepiej i przede wszystkim w metodzie main() widzimy w ogólny sposób co się dzieje. Nawet nie mając pojęcia o programowaniu jesteśmy w stanie domyślić się tego co się dzieje.

Usuwanie powtarzającego się kodu

Po wydzieleniu metod pozostaje jeszcze problem związany z tym, że część kodu się powtarza. W końcu sumowanie liczb dodatnich, czy ujemnych wygląda niemal identycznie, zmienia się jedynie lista, którą przekazujemy jako argument metody. Podobny problem dotyczy podziału liczb na dodatnie i ujemne - w metodach getPositiveNumbers() i getNegativeNumbers() zmienia się jedynie warunek w instrukcji if.

W celu rozwiązania problemu należy zapisać metody w bardziej generyczny sposób. Dodatkowo bardzo przydatne będą konstrukcje z Javy 8, czyli wyrażenia lambda.

Zamiast metod negativeNumbersSum() i positiveNumbersSum() tworzymy jedną ogólniejszą:

private static int sumNumbers(List<Integer> positiveNumbers) {
    int positiveNumbersSum = 0;
    for (int i = 0; i < positiveNumbers.size(); i++) {
        positiveNumbersSum += positiveNumbers.get(i);
    }
    return positiveNumbersSum;
}

Podobną zmianę robimy w przypadku metod getPositiveNumbers() i getNegativeNumbers(). Dodając do metody parametr typu Predicate, możemy do metody przekazać wyrażenie lambda, które określi warunek filtrowania listy.

private static List<Integer> filterNumbers(List<Integer> numbers, Predicate<Integer> predicate) {
    List<Integer> filteredNumbers = new ArrayList<>();
    for (int i = 0; i < numbers.size(); i++) {
        if (predicate.test(numbers.get(i)))
            filteredNumbers.add(numbers.get(i));
    }
    return filteredNumbers;
}

Ostatecznie mamy dzięki temu dużo krótszy kod:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.function.Predicate;

class NumbersOperations {
    public static void main(String[] args) {
        List<Integer> userNumbers = getNumbersFromUser();
        List<Integer> positiveNumbers = filterNumbers(userNumbers, x -> x >= 0);
        List<Integer> negativeNumbers = filterNumbers(userNumbers, x -> x < 0);
        int positiveNumbersSum = sumNumbers(positiveNumbers);
        int negativeNumbersSum = sumNumbers(negativeNumbers);
        printResults(positiveNumbersSum, negativeNumbersSum);
    }

    private static void printResults(int positiveNumbersSum, int negativeNumbersSum) {
        System.out.println("Suma wprowadzonych liczb dodatnich: " + positiveNumbersSum);
        System.out.println("Suma wprowadzonych liczb ujemnych: " + negativeNumbersSum);
    }

    private static int sumNumbers(List<Integer> positiveNumbers) {
        int positiveNumbersSum = 0;
        for (int i = 0; i < positiveNumbers.size(); i++) {
            positiveNumbersSum += positiveNumbers.get(i);
        }
        return positiveNumbersSum;
    }

    private static List<Integer> filterNumbers(List<Integer> numbers, Predicate<Integer> predicate) {
        List<Integer> filteredNumbers = new ArrayList<>();
        for (int i = 0; i < numbers.size(); i++) {
            if (predicate.test(numbers.get(i)))
                filteredNumbers.add(numbers.get(i));
        }
        return filteredNumbers;
    }

    private static List<Integer> getNumbersFromUser() {
        Scanner scanner = new Scanner(System.in);
        List<Integer> userNumbers = new ArrayList<>();
        System.out.println("Ile liczb wczytać?");
        int numberOfNumbers = scanner.nextInt();
        for (int i = 0; i < numberOfNumbers; i++) {
            System.out.println("Podaj kolejną liczbę:");
            userNumbers.add(scanner.nextInt());
        }
        return userNumbers;
    }
}

Możliwości języka i środowiska

Środowisko typu IntelliJ IDEA jest nam w stanie zaproponować pewne usprawnienia w kodzie. U nas na żółto będą podświetlone wszystkie pętle for. Taka tradycyjna pętla iteracyjna zawiera wiele elementów, a można ją zastąpić prostszą pętlą for-each. Kliknij na podświetlony na żółto fragment kodu, wciśnij Alt + Enter / Option + Enter i wybierz podpowiedź IntelliJ.

refactoring replace for
private static int sumNumbers(List<Integer> positiveNumbers) {
    int positiveNumbersSum = 0;
    for (Integer positiveNumber : positiveNumbers) {
        positiveNumbersSum += positiveNumber;
    }
    return positiveNumbersSum;
}

private static List<Integer> filterNumbers(List<Integer> numbers, Predicate<Integer> predicate) {
    List<Integer> filteredNumbers = new ArrayList<>();
    for (Integer number : numbers) {
        if (predicate.test(number))
            filteredNumbers.add(number);
    }
    return filteredNumbers;
}

Dużo większe korzyści możemy jednak osiągnąć, jeżeli sięgniemy po inne nowości wprowadzone w Javie 8, czyli strumienie. Wykorzystując je w swoim kodzie możemy się tak naprawdę pozbyć obu powyższych metod, bez utraty czytelności.

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

class NumbersOperations {
    public static void main(String[] args) {
        List<Integer> userNumbers = getNumbersFromUser();
        int positiveNumbersSum = userNumbers.stream()
                .filter(x -> x >= 0)
                .mapToInt(Integer::intValue)
                .sum();
        int negativeNumbersSum = userNumbers.stream()
                .filter(x -> x < 0)
                .mapToInt(Integer::intValue)
                .sum();
        printResults(positiveNumbersSum, negativeNumbersSum);
    }

    private static void printResults(int positiveNumbersSum, int negativeNumbersSum) {
        System.out.println("Suma wprowadzonych liczb dodatnich: " + positiveNumbersSum);
        System.out.println("Suma wprowadzonych liczb ujemnych: " + negativeNumbersSum);
    }

    private static List<Integer> getNumbersFromUser() {
        Scanner scanner = new Scanner(System.in);
        List<Integer> userNumbers = new ArrayList<>();
        System.out.println("Ile liczb wczytać?");
        int numberOfNumbers = scanner.nextInt();
        for (int i = 0; i < numberOfNumbers; i++) {
            System.out.println("Podaj kolejną liczbę:");
            userNumbers.add(scanner.nextInt());
        }
        return userNumbers;
    }
}

Część kodu znowu nam się częściowo powtarza, więc wydzielamy metodę i uzyskujemy ostatecznie:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.function.Predicate;

class NumbersOperations {
    public static void main(String[] args) {
        List<Integer> userNumbers = getNumbersFromUser();
        int positiveNumbersSum = getSum(userNumbers, x -> x >= 0);
        int negativeNumbersSum = getSum(userNumbers, x -> x < 0);
        printResults(positiveNumbersSum, negativeNumbersSum);
    }

    private static int getSum(List<Integer> numbers, Predicate<Integer> predicate) {
        return numbers.stream()
                .filter(predicate)
                .mapToInt(Integer::intValue)
                .sum();
    }

    private static void printResults(int positiveNumbersSum, int negativeNumbersSum) {
        System.out.println("Suma wprowadzonych liczb dodatnich: " + positiveNumbersSum);
        System.out.println("Suma wprowadzonych liczb ujemnych: " + negativeNumbersSum);
    }

    private static List<Integer> getNumbersFromUser() {
        Scanner scanner = new Scanner(System.in);
        List<Integer> userNumbers = new ArrayList<>();
        System.out.println("Ile liczb wczytać?");
        int numberOfNumbers = scanner.nextInt();
        for (int i = 0; i < numberOfNumbers; i++) {
            System.out.println("Podaj kolejną liczbę:");
            userNumbers.add(scanner.nextInt());
        }
        return userNumbers;
    }
}

Ostatecznie kod ma niemal identyczną długość jak kod wyjściowy, jednak jest podzielony na uniwersalne metody, a dzięki czytelnym nazwom kod stał się dużo bardziej czytelny. W refaktoringu nie zawsze chodzi o to, żeby kod był krótszy, można nawet założyć, że w większości przypadków będzie on dłuższy. Warto jednak poświęcić zwięzłość kodu na rzecz jego czytelności, łatwości utrzymania oraz uniwersalności i możliwości wykorzystania w innych klasach projektu.

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzielić spostrzeżeniami? Zapraszamy dyskusji na naszej grupie na Facebooku.