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);Erste: versandtWir erstatten dem Kunden das GeldZweite: storniertDie 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);Bestellung neu: kann nicht versandt werdenversandtBestellung versandt: kann nicht storniert werdenzugestelltvar 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
- Eine neue Bestellung: ihr Feld state zeigt auf NewState („neu“).
- Ship() bei einer neuen Bestellung. NewState hat keinen Pfeil Ship, also lehnt die Basisklasse ab und catch gibt den Fehler aus.
- Pay(): NewState erlaubt es und gibt PaidState zurück. Die Bestellung merkt sich nur den neuen Zustand.
- Dasselbe Ship() wie vorher, aber jetzt antwortet PaidState: Es erlaubt es, und der nächste Zustand ist ShippedState.
- Der Status ist der Name des aktuellen Zustands: versandt.
- Cancel(), während das Paket unterwegs ist. ShippedState hat keinen Pfeil Cancel: eine Wand, und keine Erstattung.
- Deliver(): von „versandt“ zu „zugestellt“.
- 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);Bestellung neu: kann nicht verpackt werdenversandtNewState, 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.
Einenum 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);Shipped → Cancelled: nicht erlaubtShippedDie 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);}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);storniertDie versandte Bestellung wurde ohne Fehler storniert, und das Paket ist unterwegs. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
ShippedState und DeliveredState haben kein eigenes Cancel, also erben sie das aus der Basisklasse, und das erlaubt es jetzt. Der Standard in der Basisklasse muss ablehnen. Dann bleibt ein vergessenes Ereignis mit einem Fehler stehen, statt leise durchzurutschen. Was erlaubt ist, wird ausdrücklich geschrieben, in dem Zustand, der es erlaubt, so wie es NewState und PaidState machen. Setze die Ablehnung in diese Zeile zurück, und die versandte Bestellung sagt wieder „Bestellung versandt: kann nicht storniert werden“.
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.
Wir erstatten dem Kunden das GeldzurückgeschicktBestellung neu: kann nicht zurückgeschickt werdenWeitere 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Werfen und eigene Exceptions · Dictionary und HashSet · Entwurfsmuster
- Geht weiter in: Der Shop: welches Muster wohin · Code-Smells: lange Methode, große Klasse, Duplikate, Primitive statt Typen, lange if-Ketten · Refactoring in kleinen Schritten
- Aufgaben zum Thema: Entwurfsmuster