Usare i criteri di ricerca per compilare il comportamento della classe per migliorare il codice

Le funzionalità di criteri di ricerca in C# forniscono la sintassi per esprimere gli algoritmi. È possibile utilizzare queste tecniche per implementare il comportamento nelle classi. È possibile combinare progettazione di classi orientate a 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 tali modelli usando le funzionalità di criteri di ricerca di C#.
  • Utilizzare la diagnostica del compilatore per convalidare l'implementazione.

Prerequisiti

È necessario configurare il computer per l'esecuzione di .NET 5, incluso il compilatore C# 9,0. Il compilatore C# 9,0 è disponibile a partire da Visual Studio 2019 versione 16,8 Preview o .NET 5,0 SDK Preview.

Creare una simulazione di un blocco del canale

In questa esercitazione verrà compilata una classe C# che simula un blocco del canale. In breve, un blocco del canale è un dispositivo che aumenta e riduce le imbarcazioni mentre passano tra due estesi di acqua a livelli diversi. Un blocco presenta due controlli e un meccanismo per modificare il livello di acqua.

Nella normale operazione, una barca entra in uno dei cancelli mentre il livello dell'acqua nel blocco corrisponde al livello di acqua sul lato della barca. Una volta nel blocco, il livello di acqua viene modificato in modo che corrisponda al livello di acqua in cui la barca lascerà il blocco. Quando il livello Water corrisponde a tale lato, viene aperto il controllo sul lato di uscita. Le misure di sicurezza assicurano che un operatore non possa creare una situazione pericolosa nel canale. Il livello di acqua può essere modificato solo quando entrambi i cancelli sono chiusi. È possibile aprire al massimo un Gate. Per aprire un controllo, il livello di acqua nel blocco deve corrispondere al livello di acqua esterno al gate aperto.

È possibile compilare una classe C# per modellare questo comportamento. Una CanalLock classe supporta i comandi per aprire o chiudere uno o più controlli. Avrebbe altri comandi per aumentare o ridurre l'acqua. La classe deve inoltre supportare le proprietà per leggere lo stato corrente delle attività di controllo e del livello di acqua. I metodi implementano le misure di sicurezza.

Definire una classe

Verrà compilata un'applicazione console per testare la CanalLock classe. Creare un nuovo progetto console per .NET 5 usando Visual Studio o l'interfaccia della riga di comando di .NET. Quindi, aggiungere 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 attività di controllo siano chiuse e che il livello di acqua sia basso. Successivamente, scrivere il codice di test seguente nel Main metodo 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}");
Console.WriteLine(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 CanalLock classe. Il codice seguente implementa i metodi della classe senza alcuna preoccupazione per le regole di sicurezza. Verranno aggiunti i test di sicurezza 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;
}

Test scritti finora. Sono state implementate le nozioni di base. A questo punto, scrivere un test per la prima condizione di errore. Alla fine dei test precedenti, entrambe le attività di controllo sono chiuse e il livello di acqua è impostato su basso. Aggiungere un test per provare ad aprire il Gate 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}");

Questo test ha esito negativo perché si apre il controllo. 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 test sono stati superati. Tuttavia, quando si aggiungono altri test, si aggiungono più if clausole e si testano proprietà diverse. Questi metodi saranno presto troppo complicati quando si aggiungono più condizionali.

Implementare i comandi con i modelli

Un metodo migliore consiste nell'usare i 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 controllo, il livello di acqua e la nuova impostazione:

Nuova impostazione Stato del Gate Livello di acqua Risultato
Chiuso Chiuso Alto Chiuso
Chiuso Chiuso Basso Chiuso
Chiuso Apri Alto Apri
Chiusa Apri Bassa Chiusa
Apri Chiusa Alto Apri
Apri Chiusa Basso Chiuso (errore)
Apri Apri Alto Apri
Apri Apri Bassa Chiuso (errore)

Il quarto e l'ultima riga della tabella hanno un testo in sciopero perché non sono validi. Il codice che si sta aggiungendo è necessario assicurarsi che il controllo High Water Gate non venga mai aperto quando l'acqua è bassa. Questi Stati possono essere codificati come un'unica 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 test sono stati superati, 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 verificare che siano stati trattati tutti gli input possibili. Ancora 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 enum tipo. Il compilatore interpreta tutti gli input possibili come tutti gli input dal tipo sottostante, in genere un int . Questa switch espressione controlla solo i valori dichiarati in enum . Per rimuovere l'avviso, è possibile aggiungere un modello di scarto catch-all per l'ultimo ARM dell'espressione. Questa condizione genera un'eccezione, perché indica un input non valido:

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

Il cambio ARM precedente deve essere l'ultimo nell' switch espressione perché corrisponde a tutti gli input. Provare a spostarlo prima nell'ordine. Ciò provoca un errore del compilatore CS8510 per il codice non eseguibile in un modello. La struttura naturale delle espressioni switch consente al compilatore di generare errori e avvisi per possibili errori. Il compilatore "safety net" semplifica la creazione di codice corretto in un minor numero di iterazioni e la libertà di combinare le armi di cambio con i caratteri jolly. Il compilatore emetterà errori se la combinazione produce bracci non raggiungibili non previsti e avvisi se si rimuove un ARM necessario.

La prima modifica consiste nel combinare tutte le armi in cui il comando deve chiudere il controllo; Questo è sempre consentito. Aggiungere il codice seguente come primo ARM nell'espressione switch:

(false, _, _) => false,

Dopo aver aggiunto il Commuter precedente, si otterranno quattro errori del compilatore, uno su ognuna delle armi in cui il comando è false . Queste armi sono già coperte dal nuovo ARM aggiunto. È possibile rimuovere le quattro righe in modo sicuro. Questo nuovo switch ARM è stato progettato per sostituire tali condizioni.

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

(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"),

Eseguire di nuovo i test e passarli. Di seguito è illustrata la versione finale del SetHighGate Metodo:

// 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 i modelli manualmente

Ora che è stata esaminata la tecnica, compilare SetLowGate manualmente i SetWaterLevel metodi e. Per iniziare, aggiungere il codice seguente per testare le operazioni non valide su questi 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 hanno esito negativo e il blocco del canale entra in uno stato non valido. Provare a implementare manualmente i metodi rimanenti. Il metodo per impostare il Gate inferiore dovrebbe essere simile al metodo per impostare il controllo superiore. Il metodo che modifica il livello di acqua ha controlli diversi, ma deve seguire una struttura simile. Potrebbe risultare utile usare lo stesso processo per il metodo che imposta il livello di acqua. Iniziare con tutti e quattro gli input: lo stato di entrambi i controlli, lo stato corrente del livello di acqua e il nuovo livello di acqua richiesto. L'espressione switch deve iniziare con:

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

Sono disponibili 16 bracci di cambio totali per il riempimento. Quindi, test e semplificazione.

È stato fatto un metodo simile al seguente?

// 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 high gate when the water is low"),
        _ => 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 test devono essere superati e il blocco del canale dovrebbe funzionare in modo sicuro.

Riepilogo

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