Zum Inhalt
wannacode

State

Kurz gesagt: State macht aus jedem Zustand eines Objekts (etwa einer Bestellung) ein eigenes Objekt, das weiß, welche Aktionen es erlaubt und welcher Zustand als Nächstes kommt, statt eines if auf den Status in jeder Methode.

Problem

Die Bestellung, die überall ihren Status prüft

Eine Bestellung im Shop geht neu → bezahlt → versandt → zugestellt, und sie kann auch storniert werden. Der Status ist Text, und jede Methode prüft ihn:

class Order
{
public string Status { get; private set; } = "neu";
public void Pay()
{
if (Status == "neu")
{
Status = "bezahlt";
}
else
{
throw new InvalidOperationException($"Bestellung {Status}: kann nicht bezahlt werden");
}
}
public void Ship()
{
if (Status == "storniert")
{
throw new InvalidOperationException($"Bestellung {Status}: kann nicht versandt werden");
}
Status = "versandt";
}
public void Deliver()
{
if (Status == "versandt")
{
Status = "zugestellt";
}
else
{
throw new InvalidOperationException($"Bestellung {Status}: kann nicht zugestellt werden");
}
}
public void Cancel()
{
if (Status == "zugestellt")
{
throw new InvalidOperationException($"Bestellung {Status}: kann nicht storniert werden");
}
if (Status != "neu")
{
Console.WriteLine("Wir erstatten dem Kunden das Geld");
}
Status = "storniert";
}
}

Zwei Bestellungen:

var first = new Order();
first.Ship();
Console.WriteLine("Erste: " + first.Status);
var second = new Order();
second.Pay();
second.Ship();
second.Cancel();
Console.WriteLine("Zweite: " + second.Status);
Ausgabe
Erste: versandt
Wir erstatten dem Kunden das Geld
Zweite: storniert

Die erste wurde versandt, ohne bezahlt zu sein: Ship prüft nur auf „storniert“. Die zweite wurde storniert, während das Paket unterwegs ist. Der Kunde bekommt das Geld zurück, und der Kurier liefert es trotzdem aus.

Niemand hat absichtlich einen Fehler geschrieben. Vier Ereignisse mal fünf Status sind zwanzig Kombinationen, und jede Methode erinnert sich nur an einige. Ein neuer Status „verpackt“ heißt, über alle vier Methoden neu nachzudenken, und die vergessene Kombination rutscht trotzdem leise durch.

Die Idee

State: Jeder Zustand weiß, was er erlaubt

Die Frage „Ist dieses Ereignis jetzt erlaubt?“ hat eine Antwort, die vom Zustand abhängt. Also wandert die Antwort in den Zustand selbst.

Jeder Zustand ist ein Objekt einer eigenen Klasse. Er sagt, welche Ereignisse er erlaubt und welcher Zustand als Nächstes kommt. Die gemeinsame Basisklasse OrderState lehnt jedes Ereignis mit einem klaren Fehler ab, und jeder Zustand überschreibt nur die erlaubten. Die Bestellung prüft nichts: Sie gibt das Ereignis an den aktuellen Zustand weiter und merkt sich, was dieser zurückgibt.

Das ist das State-Muster. Es hat drei Rollen:

  • die Bestellung hält den aktuellen Zustand. In Büchern heißt sie der Kontext;
  • die Basisklasse OrderState: alle Ereignisse, und jedes lehnt standardmäßig ab;
  • die konkreten Zustände: NewState, PaidState, ShippedState, DeliveredState, CancelledState.

Sieh es dir an

Die fünf Zustände und die Bestellung

Die Basisklasse: Jedes Ereignis lehnt mit einem Text ab, der den Namen des Zustands enthält.

abstract class OrderState
{
public abstract string Name { get; }
public virtual OrderState Pay() => throw Refuse("bezahlt werden");
public virtual OrderState Ship() => throw Refuse("versandt werden");
public virtual OrderState Deliver() => throw Refuse("zugestellt werden");
public virtual OrderState Cancel() => throw Refuse("storniert werden");
protected InvalidOperationException Refuse(string action)
{
return new InvalidOperationException($"Bestellung {Name}: kann nicht {action}");
}
}

Jeder Zustand überschreibt nur, was er erlaubt. Das Stornieren einer bezahlten Bestellung erstattet das Geld, das einer neuen erstattet nichts, weil nichts bezahlt wurde. Dieses Verhalten steht im eigenen Zustand, ohne if:

class NewState : OrderState
{
public override string Name => "neu";
public override OrderState Pay() => new PaidState();
public override OrderState Cancel() => new CancelledState();
}
class PaidState : OrderState
{
public override string Name => "bezahlt";
public override OrderState Ship() => new ShippedState();
public override OrderState Cancel()
{
Console.WriteLine("Wir erstatten dem Kunden das Geld");
return new CancelledState();
}
}
class ShippedState : OrderState
{
public override string Name => "versandt";
public override OrderState Deliver() => new DeliveredState();
}
class DeliveredState : OrderState
{
public override string Name => "zugestellt";
}
class CancelledState : OrderState
{
public override string Name => "storniert";
}

Die Bestellung hält den aktuellen Zustand und gibt jedes Ereignis an ihn weiter:

class Order
{
private OrderState state = new NewState();
public string Status => state.Name;
public void Pay() => state = state.Pay();
public void Ship() => state = state.Ship();
public void Deliver() => state = state.Deliver();
public void Cancel() => state = state.Cancel();
}

Die zwei Kombinationen aus dem Problem, und dann der ganze Weg:

var order = new Order();
try
{
order.Ship();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
order.Pay();
order.Ship();
Console.WriteLine(order.Status);
try
{
order.Cancel();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
order.Deliver();
Console.WriteLine(order.Status);
Ausgabe
Bestellung neu: kann nicht versandt werden
versandt
Bestellung versandt: kann nicht storniert werden
zugestellt

var order = new Order();
try
{
order.Ship();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
order.Pay();
order.Ship();
Console.WriteLine(order.Status);
try
{
order.Cancel();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
order.Deliver();
Console.WriteLine(order.Status);
Schritt für Schritt, in Worten
  1. Eine neue Bestellung: ihr Feld state zeigt auf NewState („neu“).
  2. Ship() bei einer neuen Bestellung. NewState hat keinen Pfeil Ship, also lehnt die Basisklasse ab und catch gibt den Fehler aus.
  3. Pay(): NewState erlaubt es und gibt PaidState zurück. Die Bestellung merkt sich nur den neuen Zustand.
  4. Dasselbe Ship() wie vorher, aber jetzt antwortet PaidState: Es erlaubt es, und der nächste Zustand ist ShippedState.
  5. Der Status ist der Name des aktuellen Zustands: versandt.
  6. Cancel(), während das Paket unterwegs ist. ShippedState hat keinen Pfeil Cancel: eine Wand, und keine Erstattung.
  7. Deliver(): von „versandt“ zu „zugestellt“.
  8. Für die beiden verbotenen Kombinationen hat niemand eine Prüfung geschrieben. Sie haben keinen Pfeil, also werden sie abgelehnt.

Von „neu“ gibt es keinen Ship-Pfeil, also wird das Versenden abgelehnt. Von „versandt“ gibt es keinen Cancel-Pfeil. Für diese beiden Kombinationen hat niemand eine Prüfung geschrieben: Sie sind verboten, weil kein Zustand sie erlaubt hat.

Sieh es dir an

Ein neues Ereignis, ohne die anderen Zustände anzufassen

Das Lager will das Paket gepackt haben, bevor es rausgeht: bezahlt → verpackt → versandt. Das neue Ereignis kommt in die Basisklasse und lehnt wie die anderen ab:

public virtual OrderState Pack() => throw Refuse("verpackt werden");

PaidState erlaubt jetzt Pack statt Ship, und der neue PackedState erlaubt Ship und das Stornieren mit Erstattung:

class PaidState : OrderState
{
public override string Name => "bezahlt";
public override OrderState Pack() => new PackedState();
public override OrderState Cancel()
{
Console.WriteLine("Wir erstatten dem Kunden das Geld");
return new CancelledState();
}
}
class PackedState : OrderState
{
public override string Name => "verpackt";
public override OrderState Ship() => new ShippedState();
public override OrderState Cancel()
{
Console.WriteLine("Wir erstatten dem Kunden das Geld");
return new CancelledState();
}
}

Die Bestellung bekommt die Methode Pack, die das Ereignis nur weitergibt:

public void Pack() => state = state.Pack();
var order = new Order();
try
{
order.Pack();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
order.Pay();
order.Pack();
order.Ship();
Console.WriteLine(order.Status);
Ausgabe
Bestellung neu: kann nicht verpackt werden
versandt

NewState, ShippedState, DeliveredState und CancelledState wurden nicht angefasst, und das Packen ist in ihnen verboten, ohne dass du dafür eine Zeile schreibst. Eine bezahlte Bestellung lässt sich nicht mehr direkt versenden, weil PaidState kein Ship hat. Bei der Kette von ifs hätte dir das neue Ereignis in jeder Methode einfallen müssen.

Sieh es dir an

Ohne Klasse pro Zustand: ein Enum und eine Tabelle

Hier erstattet das Stornieren einer bezahlten Bestellung das Geld: Der Zustand tut etwas, und das gehört in eine Klasse. Wenn die Zustände nichts tun und nur sagen, welcher Übergang erlaubt ist, reicht weniger.

Ein enum ist ein Typ mit einer festen Liste von Werten. Der Status ist einer davon, und die Tabelle sagt, in welche Status du von jedem aus wechseln kannst:
enum Status { New, Paid, Shipped, Delivered, Cancelled }
class Order
{
private static readonly Dictionary<Status, Status[]> Next = new()
{
[Status.New] = [Status.Paid, Status.Cancelled],
[Status.Paid] = [Status.Shipped, Status.Cancelled],
[Status.Shipped] = [Status.Delivered],
[Status.Delivered] = [],
[Status.Cancelled] = [],
};
public Status Status { get; private set; } = Status.New;
public void MoveTo(Status next)
{
if (!Next[Status].Contains(next))
{
throw new InvalidOperationException($"{Status} → {next}: nicht erlaubt");
}
Status = next;
}
}
var order = new Order();
order.MoveTo(Status.Paid);
order.MoveTo(Status.Shipped);
try
{
order.MoveTo(Status.Cancelled);
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
Console.WriteLine(order.Status);
Ausgabe
Shipped → Cancelled: nicht erlaubt
Shipped

Die Prüfung steht an einer Stelle, und die Übergänge sind Daten: eine Zeile in der Tabelle für einen neuen Status. Das ist die ehrliche Wahl, wenn die Zustände kein Verhalten haben. Sobald irgendein Übergang etwas tun muss (Geld erstatten, eine E-Mail schicken, festhalten, wann versandt wurde), wachsen um die Tabelle herum ifs, und dann sind die Klassen von State es wert.

Genauer, für Neugierige

Die Zustände hier haben keine Felder, also würde ein Objekt von jedem für alle Bestellungen genügen. In C# kann das ein statisches Feld mit einem fertigen Objekt für jeden Zustand sein (static readonly OrderState Paid = new PaidState();). Das spart Objekte, hat aber einen Preis: Sobald ein Zustand ein Feld bekommt (den Erstattungsbetrag, das Zahlungsdatum), teilt sich ein gemeinsames Objekt es zwischen allen Bestellungen. Deshalb ist new bei jedem Übergang der sicherere Anfang.

Probier es aus

Sag die Ausgabe voraus

Die Bestellung wird bezahlt und sofort storniert, und dann versucht jemand, sie zu versenden. Was gibt das Programm aus?

var order = new Order();
order.Pay();
order.Cancel();
Console.WriteLine(order.Status);
try
{
order.Ship();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
Antwort

Probier es aus

Finde den Fehler

Fast jeder Zustand erlaubt das Stornieren, also beschließt jemand, es einmal in der Basisklasse zu schreiben:

abstract class OrderState
{
public abstract string Name { get; }
public virtual OrderState Pay() => throw Refuse("bezahlt werden");
public virtual OrderState Ship() => throw Refuse("versandt werden");
public virtual OrderState Deliver() => throw Refuse("zugestellt werden");
public virtual OrderState Cancel() => new CancelledState();
protected InvalidOperationException Refuse(string action)
{
return new InvalidOperationException($"Bestellung {Name}: kann nicht {action}");
}
}
var order = new Order();
order.Pay();
order.Ship();
order.Cancel();
Console.WriteLine(order.Status);
Ausgabe
storniert

Die versandte Bestellung wurde ohne Fehler storniert, und das Paket ist unterwegs. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Wann du es nicht brauchst

State hat seinen Preis: eine Klasse pro Zustand und eine Basisklasse statt eines Feldes. Es lohnt sich nicht, wenn:

  • es zwei Zustände und einen Übergang gibt. „Bezahlt oder nicht“ ist ein boolesches Feld;
  • die Zustände kein Verhalten haben, nur erlaubte Übergänge. Dann reichen ein Enum und eine Tabelle von der Karte davor;
  • die Übergänge von außen kommen (der Shop-Inhaber richtet sie im Admin-Bereich ein). Klassen leben im Code, Daten dagegen lassen sich ohne neue Version des Programms ändern.

Es lohnt sich, wenn es mehr Zustände gibt, jeder bei einem Ereignis etwas Eigenes tut und immer neue dazukommen.

Übung

Rückgabe innerhalb von 14 Tagen

Löse die Aufgabe in deinem Editor.

Rückgabe. Der Kunde kann eine zugestellte Bestellung zurückgeben, und der Shop erstattet das Geld. Füge ein Ereignis ReturnOrder und einen Zustand „zurückgeschickt“ hinzu. Er ist nur von „zugestellt“ aus erlaubt. Bring eine Bestellung ganz bis zugestellt und gib sie zurück, dann versuche, eine neue Bestellung zurückzugeben.

Ausgabe
Wir erstatten dem Kunden das Geld
zurückgeschickt
Bestellung neu: kann nicht zurückgeschickt werden

Weitere Aufgaben

  • Verlauf. Die Bestellung merkt sich die Status, durch die sie gegangen ist, und gibt sie so aus: neu → bezahlt → versandt → zugestellt. Wo hältst du den nächsten Status fest: in der Bestellung oder in jedem Zustand?
  • Eine E-Mail bei jeder Änderung. Nach jedem erlaubten Übergang bekommt der Kunde eine Zeile „E-Mail: Die Bestellung ist bezahlt“. Sorge dafür, dass ein abgelehntes Ereignis keine E-Mail schickt.
  • Verpackt mit der Tabelle. Füge „verpackt“ zur Version mit dem Enum und der Tabelle hinzu. Zähle, wie viele Stellen du dort geändert hast und wie viele in der Version mit Klassen.

Test

Teste dich

1. Der Shop bekommt ein Ereignis „packen“. Was schreibst du in die Zustände, in denen es nicht erlaubt ist?
2. Was hält die Bestellung bei State?
3. Jemand erlaubt das Stornieren standardmäßig in der Basisklasse, damit man es nicht in jedem Zustand schreiben muss. Was passiert mit einer versandten Bestellung?
4. Wann reichen ein Enum und eine Übergangstabelle, ohne Klasse pro Zustand?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·