Laboratoria: 4 XII

Celem zajęć jest poznanie konceptu typestate programming w języku Rust. W ogólności jest to wzorzec umożliwiający implementację automatu skończonego, której poprawność gwarantuje system typów na etapie kompilacji.

Wzorzec projektowy builder

Na laboratoriach dostarczymy dwie różne implementacje buildera dla prostej struktury:

struct Point {
  x: i32,
  y: i32,
}

Docelowo chcemy móc napisać:

let point = PointBuilder::new()
  .x(0)
  .y(1)
  .build();

let point = PointBuilder::new()
  .y(1)
  .x(0)
  .build();

jednocześnie zabraniając wielokrotnego przypisania wartości jednemu polu lub utworzenia instancji Point bez podania wartości wszystkich pól.

Krok 1. (runtime checking) (*)

Dostarczamy najprostszą implementację buildera dla typu Point. Na razie błąd nieprawidłowego użycia (wielokrotne przypisanie wartości jednemu polu lub próba zbudowania finalnego obiektu bez przypisania wartości wszystkim jego polom) zgłaszamy w runtime. Sugerowane są dwie możliwości sygnalizacji błędu:

  • metody buildera zwracają odpowiednio Result<Self, String> i Result<Point, String>
  • napotkawszy niepoprawne użycie panikujemy

Dla pełności rozwiązania dorzucamy unit testy, które weryfikują implementację (przydatne może być makro #[should_panic]).

Krok 2. (compile-time checking) (*)

Teraz zajmiemy się alternatywną implementacją, która korzystając z konceptu typestate programming przesunie weryfikację użycia buildera z runtime’u do etapu kompilacji. Aby to osiągnąć, uczynimy typ PointBuilder generycznym po stanie w jakim się znajduje. (Przez stan, mamy tu na myśli status przypisania wartości do pól.)

Dla każdego stanu stworzymy nowy typ reprezentujący go:

enum NothingSet {}
enum XSetYNotSet {}
enum XNotSetYSet {}
enum AllSet {}

Typy tworzymy jako puste Enum y zapewniając tym samym, że nie można utworzyć obiektu stanu. Nie jest to konieczne, ale daje to pewną czystość semantyczną kodu.

Dostarczamy również wspólny mianownik dla wszystkich możliwych stanów:

trait BuilderState {}
impl BuilderState for NothingSet {}
...

Ostatecznie typ naszego Buildera może przyjąć postać (choć nie jest to jedyna możliwość):

struct PointBuilder<S: BuilderState> {
  x: Option<i32>,
  y: Option<i32>,
}

Pozostała część zadania polega na dostarczeniu implementacji dla PointBuilder zależnej od jego argumentu generycznego. Innymi słowy, dostarczamy bloki typu:

impl PointBuilder<NothingSet> {
  ...
}

Na koniec dopisujemy testy potwierdzające poprawność rozwiązania. Tym razem, będziemy musieli zweryfikować, że konkretny kod się nie kompiluje. Do tego, sugeruję użyć bibliotekę trybuild (https://docs.rs/trybuild/).

Krok 3. (redukcja ilości stanów)

Rozszerzamy strukturę Point tak, aby posiadała 3 koordynaty i zmieniamy reprezentację stanu tak, aby zamiast 8 rozłącznych bloków impl PointBuilder<JedenZOśmiuStanów> móc pogrupować wspolną logikę dla podzbiorów stanów.

Sugerowane są dwie możliwości:

  • PointBuilder ma trzy parametry generyczne, po jednym na stan każdej współrzędnej
  • wprowadzamy nowe trait y grupujące odpowiednie podzbiory stanów