Marcin Lasota
Marcin LasotaJava Developer @ devcave.pl

Idiotoodporne API klasy, czyli jakie?

O interfejsie klasy, który podpowie klientowi jak jej użyć.
21.04.20224 min
Idiotoodporne API klasy, czyli jakie?

W wpisie z serii „Effective Java” na moim blogu omawiałem wzorzec projektowy Builder. Była tam mowa o tym, że zazwyczaj w metodzie public Goal build() przed zbudowaniem obiektu weryfikujemy, czy wszystkie wymagane pola zostały podane. Dla przykładowej klasy z poprzedniego wpisu wyglądało to tak:

public class Goal {
  private String name;
  private String description;
  private List<Level> levels;
  private Checklist checklist;
  private LocalDate deadline;
  private boolean achieved;
  
  public static Builder builder() {
    return new Builder();
  }

  public static final class Builder {
    //...
    
    public Goal build() {
      if(name.isEmpty()){
        throw new IllegalStateException("Name cannot be empty");
      }
      if(description.isEmpty()){
        throw new IllegalStateException("Name cannot be empty");
      }
      if(levels.isEmpty()){
        throw new IllegalStateException("Levels cannot be empty");
      }

      Goal goal = new Goal();
      goal.deadline = this.deadline;
      goal.name = this.name;
      goal.checklist = this.checklist;
      goal.levels = this.levels;
      goal.description = this.description;
      goal.achieved = this.achieved;
      return goal;
    }
  }
}


Mamy tu dwa problemy.

  1. Dla każdego wymaganego pola musimy pisać if, który sprawdza, czy pole zostało podane. Jeśli nie, rzuca wyjątkiem, co nie jest zbyt ładnym rozwiązaniem.
  2. Klient, który używa buildera nie ma pojęcia o tym, co jest wymagane, a co nie. Dowie się dopiero wtedy, gdy spróbuje odpalić program i dostanie wyjątek.


Możemy uprzyjemnić kod dla klienta i wyeliminować te problemy poprzez odpowiednio zaprojektowanie klasy - a z pomocą przychodzą interfejsy.

Idiotoodporne API klasy

W przykładzie z builderem będzie to działało tak, że przed zbudowaniem obiektu klient będzie musiał użyć wszystkich wymaganych metod w odpowiedniej kolejności. Jeśli tego nie zrobi, nie będzie miał nawet możliwości wywołania metody build(). W skrócie - będzie cały czas prowadzony za rączkę.

Do klasy (w tym przypadku Goal) będziemy musieli dodać wewnętrzne publiczne interfejsy. Wewnętrzne, bo nie ma potrzeby definiowania ich w globalnym zasięgu, przez co drzewo pakietowe może całkiem spuchnąć (takich interfejsów może być sporo). Preferuję zrobić to na końcu klasy:

public class Goal {
  //fields

  public static class Builder {
    //builder implementation
  }

  public interface NeedName {
    public NeedDescription name(String name);
  }

  public interface NeedDescription {
    NeedLevels description(String description);
  }

  public interface NeedLevels {
    NeedLevels addLevel(Level level);

    CanBeBuild and();
  }

  public interface CanBeBuild {
    CanBeBuild checklist(Checklist checklist);

    CanBeBuild deadline(LocalDate deadline);

    CanBeBuild achieved();

    Goal build();
  }
}


Jeśli klasa będzie używana tylko w tym samym pakiecie, interfejsy mogą pozostać package-private, czyli można im usunąć modyfikator dostępu public.

Interfejsy te odwzorowują kolejne kroki w używaniu API klasy. W tym przykładzie interfejsy nazwane według konwencji Need* określają kolejne pola, które builder musi mieć zdefiniowane oraz zwracają kolejny interfejs, który odpowiada za następny krok. Ostatni interfejs CanBeBuild określa, że klasa jest już gotowa do wykonania finalnej metody (w tym przypadku build()) oraz umożliwia jeszcze ustawienie opcjonalnych pól.

Teraz wystarczy sprawić, żeby builder implementował wszystkie zdefiniowane interfejsy oraz zmodyfikować metodę build(), aby zwracała interfejs buildera reprezentujący pierwszy krok. W tym przypadku to NeedName:

public class Goal {
  //fields
  
  public static NeedName builder() {
    return new Builder();
  }
    
  public static class Builder {
    //builder implementation
  }

  //interfaces
}


Cała klasa z builderem będzie wyglądać następująco:

public class Goal {
  private String name;
  private String description;
  private List<Level> levels;
  private Checklist checklist;
  private LocalDate deadline;
  private boolean achieved;

  public static NeedName builder() {
    return new Builder();
  }

  public static class Builder implements NeedName, NeedDescription, NeedLevels, CanBeBuild {
    private String name;
    private String description;
    private List<Level> levels;
    private Checklist checklist;
    private LocalDate deadline;
    private boolean achieved = false;

    @Override
    public Builder name(String name) {
      this.name = name;
      return this;
    }

    @Override
    public Builder description(String description) {
      this.description = description;
      return this;
    }

    @Override
    public Builder checklist(Checklist checklist) {
      this.checklist = checklist;
      return this;
    }

    @Override
    public Builder deadline(LocalDate deadline) {
      this.deadline = deadline;
      return this;
    }

    @Override
    public Builder achieved() {
      this.achieved = true;
      return this;
    }

    @Override
    public Builder addLevel(Level level) {
      levels.add(level);
      return this;
    }

    @Override
    public Builder and() {
      return this;
    }

    public Goal build() {
      Goal goal = new Goal();
      goal.deadline = this.deadline;
      goal.name = this.name;
      goal.checklist = this.checklist;
      goal.levels = this.levels;
      goal.description = this.description;
      goal.achieved = this.achieved;
      return goal;
    }
  }

  public interface NeedName {
    public NeedDescription name(String name);
  }

  public interface NeedDescription {
    NeedLevels description(String description);
  }

  public interface NeedLevels {
    NeedLevels addLevel(Level level);

    CanBeBuild and();
  }

  public interface CanBeBuild {
    CanBeBuild checklist(Checklist checklist);

    CanBeBuild deadline(LocalDate deadline);

    CanBeBuild achieved();

    Goal build();
  }
}


Trochę zabiegu z tym jest, szczególnie w bardziej rozbudowanych przypadkach, ale dostajemy dzięki temu „idiotoodporną” klasę, której nie da się źle użyć. Bo jak teraz wygląda korzystanie z buildera?

Dobieramy się do instancji buildera i próbujemy wywołać metodę, a tam dostępna jest tylko opcja podania name():

builder ostepne metody

Zwracany jest interfejs NeedDescription i analogicznie można użyć tylko jednej metody:

builder dostepne metody

Następnie zwracany jest interfejs NeedLevels, który umożliwia dodanie dowolnej liczby poziomów do naszego celu:

builder dostepne metody builder dostepne metody builder dostepne metody

i z pomocą metody and() - przejście do ostatniego kroku, gdzie mamy już możliwość zbudowania obiektu lub jeszcze użyć jakiejś opcjonalnej metody:

builder dostepne metody

Więc dodajmy jeszcze deadline:

builder dostepne metody

i możemy w końcu zbudować klasę:

builder dostepne metody

Jak widać, dzięki takiemu rozwiązaniu możemy w dowolny sposób manipulować tym, w jaki sposób klasa ma być używana przez klienta.

Można tak zaprojektować dowolną klasę. Jest to całkiem elastyczne i uniwersalne rozwiązanie.

Jeśli spodobał Ci się wpis, zajrzyj na bloga autora.

<p>Loading...</p>