Condividi tramite


Utilizzare il pattern matching per costruire il comportamento della classe per un codice migliore

Le funzionalità di pattern matching in C# forniscono la sintassi per esprimere gli algoritmi. È possibile usare queste tecniche per implementare il comportamento nelle classi. È possibile combinare la progettazione di classi orientate agli oggetti con un'implementazione orientata ai dati per fornire codice conciso durante la modellazione di oggetti reali.

In questa esercitazione si apprenderà come:

  • Esprimere le classi orientate agli oggetti usando i modelli di dati.
  • Implementare questi modelli usando le funzionalità di pattern matching di C#.
  • Sfruttare la diagnostica del compilatore per convalidare l'implementazione.

Prerequisiti

  • La versione più recente .NET SDK
  • editor di Visual Studio Code
  • Il DevKit C#

Creare una simulazione di un blocco del canale

In questa esercitazione si creerà una classe C# che simula un blocco del canale . Brevemente, una serratura del canale è un dispositivo che alza e abbassa le barche mentre viaggiano tra due tratti d'acqua a livelli diversi. Una serratura ha due cancelli e un meccanismo per cambiare il livello dell'acqua.

Nel suo normale funzionamento, una barca entra in uno dei cancelli mentre il livello dell'acqua nella serratura corrisponde al livello dell'acqua sul lato della barca entra. Una volta nella serratura, il livello dell'acqua viene modificato in modo che corrisponda al livello dell'acqua in cui la barca lascia la serratura. Una volta che il livello dell'acqua corrisponde a quel lato, si apre il cancello sul lato uscita. Le misure di sicurezza assicurano che un operatore non possa creare una situazione pericolosa nel canale. Il livello dell'acqua può essere modificato solo quando entrambe le porte sono chiuse. Al massimo un cancello può essere aperto. Per aprire un cancello, il livello dell'acqua nella serratura deve corrispondere al livello dell'acqua all'esterno del cancello aperto.

È possibile compilare una classe C# per modellare questo comportamento. Una classe CanalLock supporta i comandi per aprire o chiudere uno dei due gate. Avrebbe altri comandi per sollevare o abbassare l'acqua. La classe deve anche supportare le proprietà per leggere lo stato corrente di entrambi i cancelli e il livello dell'acqua. I metodi implementano le misure di sicurezza.

Definire una classe

Tu costruisci un'applicazione console per testare la tua classe CanalLock. Creare un nuovo progetto console per .NET 5 usando Visual Studio o l'interfaccia della riga di comando di .NET. Aggiungere quindi una nuova classe e denominarla CanalLock. Successivamente, progettare l'API pubblica, ma lasciare i metodi non implementati:

public enum WaterLevel
{
    Low,
    High
}
public class CanalLock
{
    // Query canal lock state:
    public WaterLevel CanalLockWaterLevel { get; private set; } = WaterLevel.Low;
    public bool HighWaterGateOpen { get; private set; } = false;
    public bool LowWaterGateOpen { get; private set; } = false;

    // Change the upper gate.
    public void SetHighGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change the lower gate.
    public void SetLowGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change water level.
    public void SetWaterLevel(WaterLevel newLevel)
    {
        throw new NotImplementedException();
    }

    public override string ToString() =>
        $"The lower gate is {(LowWaterGateOpen ? "Open" : "Closed")}. " +
        $"The upper gate is {(HighWaterGateOpen ? "Open" : "Closed")}. " +
        $"The water level is {CanalLockWaterLevel}.";
}

Il codice precedente inizializza l'oggetto in modo che entrambe le porte siano chiuse e il livello dell'acqua sia basso. Scrivere quindi il codice di test seguente nel metodo Main per guidare l'utente durante la creazione di una prima implementazione della classe :

// Create a new canal lock:
var canalGate = new CanalLock();

// State should be doors closed, water level low:
Console.WriteLine(canalGate);

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat enters lock from lower gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

canalGate.SetWaterLevel(WaterLevel.High);
Console.WriteLine($"Raise the water level: {canalGate}");

canalGate.SetHighGate(open: true);
Console.WriteLine($"Open the higher gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");
Console.WriteLine("Boat enters lock from upper gate");

canalGate.SetHighGate(open: false);
Console.WriteLine($"Close the higher gate: {canalGate}");

canalGate.SetWaterLevel(WaterLevel.Low);
Console.WriteLine($"Lower the water level: {canalGate}");

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

Aggiungere quindi una prima implementazione di ogni metodo nella classe CanalLock. Il codice seguente implementa i metodi della classe senza preoccuparsi delle regole di sicurezza. I test di sicurezza verranno aggiunti in un secondo momento:

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = open;
}

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = open;
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = newLevel;
}

I test che hai scritto finora passano. Sono state implementate le nozioni di base. Scrivere ora un test per la prima condizione di errore. Alla fine dei test precedenti, entrambi i cancelli sono chiusi e il livello dell'acqua è impostato su basso. Aggiungere un test per provare ad aprire il cancello superiore:

Console.WriteLine("=============================================");
Console.WriteLine("     Test invalid commands");
// Open "wrong" gate (2 tests)
try
{
    canalGate = new CanalLock();
    canalGate.SetHighGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("Invalid operation: Can't open the high gate. Water is low.");
}
Console.WriteLine($"Try to open upper gate: {canalGate}");

Poiché si apre il cancello, questo test ha esito negativo. Come prima implementazione, è possibile correggerla con il codice seguente:

// Change the upper gate.
public void SetHighGate(bool open)
{
    if (open && (CanalLockWaterLevel == WaterLevel.High))
        HighWaterGateOpen = true;
    else if (open && (CanalLockWaterLevel == WaterLevel.Low))
        throw new InvalidOperationException("Cannot open high gate when the water is low");
}

I tuoi test passano. Tuttavia, man mano che si aggiungono altri test, si aggiungono altre clausole if e si testano proprietà diverse. A breve, questi metodi diventano troppo complicati man mano che si aggiungono altri condizionali.

Implementare i comandi con i modelli

Un modo migliore consiste nell'usare modelli per determinare se l'oggetto è in uno stato valido per eseguire un comando. È possibile esprimere se un comando è consentito come funzione di tre variabili: lo stato del cancello, il livello dell'acqua e la nuova impostazione:

Nuova impostazione Stato del cancello Livello dell'acqua Risultato
Chiuso Chiuso Alto Chiuso
Chiuso Chiuso Basso Chiuso
Chiuso Aperto Alto Chiuso
chiuso Apri Basso chiuso
Aperto Chiuso Alto Aperto
Aperto Chiuso Basso Chiuso (errore)
Aperto Aperto Alto Aperto
Apri Apri Basso Chiuso (Errore)

Le quarta e ultima righe nella tabella hanno il testo barrato perché il contenuto non è valido. Il codice che stai aggiungendo dovrebbe assicurarsi che la paratoia non venga mai aperta quando il livello dell'acqua è basso. Questi stati possono essere codificati come singola espressione switch (tenere presente che false indica "Closed"):

HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
{
    (false, false, WaterLevel.High) => false,
    (false, false, WaterLevel.Low) => false,
    (false, true, WaterLevel.High) => false,
    (false, true, WaterLevel.Low) => false, // should never happen
    (true, false, WaterLevel.High) => true,
    (true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
    (true, true, WaterLevel.High) => true,
    (true, true, WaterLevel.Low) => false, // should never happen
};

Provare questa versione. I tuoi test superano, convalidando il codice. La tabella completa mostra le possibili combinazioni di input e risultati. Ciò significa che l'utente e altri sviluppatori possono esaminare rapidamente la tabella e osservare che sono stati trattati tutti gli input possibili. Anche più semplice, il compilatore può essere utile. Dopo aver aggiunto il codice precedente, è possibile notare che il compilatore genera un avviso: CS8524 indica che l'espressione switch non copre tutti gli input possibili. Il motivo di tale avviso è che uno degli input è un tipo enum. Il compilatore interpreta "tutti gli input possibili" come tutti gli input del tipo sottostante, in genere un int. Questa espressione switch controlla solo i valori dichiarati nel enum. Per rimuovere l'avviso, è possibile aggiungere un modello di eliminazione universale per l'ultima parte dell'espressione. Questa condizione genera un'eccezione perché indica un input non valido:

_  => throw new InvalidOperationException("Invalid internal state"),

Il braccio dell'interruttore precedente deve essere l'ultimo nell'espressione switch perché si accorda con tutti gli ingressi. Sperimenta spostandolo più in alto nell'ordine. Ciò causa un errore del compilatore CS8510 per il codice non raggiungibile in un modello. La struttura naturale delle espressioni switch consente al compilatore di generare errori e avvisi per possibili errori. Il compilatore funge da "rete di sicurezza", semplificando la creazione di codice corretto in un minor numero di iterazioni e offrendo la libertà di combinare i rami switch con i caratteri jolly. Il compilatore genera errori se la combinazione genera braccia non raggiungibili e avvisi se si rimuove un braccio necessario.

La prima modifica consiste nel combinare tutte le braccia in cui il comando è quello di chiudere il cancello; questo è sempre consentito. Aggiungere il codice seguente come primo braccio nell'espressione switch:

(false, _, _) => false,

Dopo aver aggiunto il braccio switch precedente, si otterranno quattro errori del compilatore, uno su ognuno dei bracci in cui il comando è false. Queste braccia sono già coperte dal braccio appena aggiunto. È possibile rimuovere in modo sicuro queste quattro righe. Hai previsto che questa nuova leva dell'interruttore sostituisca quelle condizioni.

Successivamente, è possibile semplificare le quattro braccia in cui il comando consiste nell'aprire il cancello. In entrambi i casi in cui il livello dell'acqua è elevato, il cancello può essere aperto. (In un caso, è già aperto.) Un caso in cui il livello dell'acqua è basso genera un'eccezione e l'altro caso non dovrebbe verificarsi. Dovrebbe essere sicuro generare la stessa eccezione se il blocco dell'acqua è già in uno stato non valido. È possibile apportare le seguenti semplificazioni per tali braccia:

(true, _, WaterLevel.High) => true,
(true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
_ => throw new InvalidOperationException("Invalid internal state"),

Esegui di nuovo i test, e poi passano. Ecco la versione finale del metodo SetHighGate:

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _,    _)               => false,
        (true, _,     WaterLevel.High) => true,
        (true, false, WaterLevel.Low)  => throw new InvalidOperationException("Cannot open high gate when the water is low"),
        _                              => throw new InvalidOperationException("Invalid internal state"),
    };
}

Implementare gli schemi manualmente

Ora che hai visto la tecnica, riempi tu stesso i metodi SetLowGate e SetWaterLevel. Per iniziare, aggiungere il codice seguente per testare operazioni non valide su tali metodi:

Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetLowGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't open the lower gate. Water is high.");
}
Console.WriteLine($"Try to open lower gate: {canalGate}");
// change water level with gate open (2 tests)
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetLowGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.High);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't raise water when the lower gate is open.");
}
Console.WriteLine($"Try to raise water with lower gate open: {canalGate}");
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetHighGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.Low);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't lower water when the high gate is open.");
}
Console.WriteLine($"Try to lower water with high gate open: {canalGate}");

Eseguire di nuovo l'applicazione. ** È possibile vedere che i nuovi test falliscono e la chiusa del canale entra in uno stato non valido. Prova a implementare i metodi rimanenti da solo. Il metodo per impostare il cancello inferiore deve essere simile al metodo per impostare il cancello superiore. Il metodo che modifica il livello dell'acqua ha controlli diversi, ma deve seguire una struttura simile. Potrebbe risultare utile usare lo stesso processo per il metodo che imposta il livello dell'acqua. Inizia con tutti e quattro gli input: lo stato di entrambe le porte, lo stato corrente del livello dell'acqua e il nuovo livello dell'acqua richiesto. L'espressione switch deve iniziare con:

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // elided
};

Hai 16 leve di scambio totali da riempire. Quindi, testare e semplificare.

Hai sviluppato dei metodi simili a questo?

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = (open, LowWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _, _) => false,
        (true, _, WaterLevel.Low) => true,
        (true, false, WaterLevel.High) => throw new InvalidOperationException("Cannot open low gate when the water is high"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
    {
        (WaterLevel.Low, WaterLevel.Low, true, false) => WaterLevel.Low,
        (WaterLevel.High, WaterLevel.High, false, true) => WaterLevel.High,
        (WaterLevel.Low, _, false, false) => WaterLevel.Low,
        (WaterLevel.High, _, false, false) => WaterLevel.High,
        (WaterLevel.Low, WaterLevel.High, false, true) => throw new InvalidOperationException("Cannot lower water when the high gate is open"),
        (WaterLevel.High, WaterLevel.Low, true, false) => throw new InvalidOperationException("Cannot raise water when the low gate is open"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

I tuoi test dovrebbero passare e la conca del canale dovrebbe operare in modo sicuro.

Sommario

In questa esercitazione si è appreso come usare i criteri di ricerca per controllare lo stato interno di un oggetto prima di applicare eventuali modifiche a tale stato. È possibile controllare le combinazioni di proprietà. Dopo aver compilato tabelle per una di queste transizioni, si testa il codice, quindi si semplifica la leggibilità e la gestibilità. Questi refactoring iniziali potrebbero suggerire altri refactoring che convalidano lo stato interno o gestiscono altre modifiche api. Questa esercitazione combina classi e oggetti con un approccio più orientato ai dati basato su modelli per implementare tali classi.