Usare i criteri di ricerca per definire il comportamento delle classi e migliorare il codice

Le funzionalità dei criteri di ricerca 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 a oggetti con un'implementazione orientata ai dati per fornire codice conciso per la modellazione di oggetti reali.

Questa esercitazione illustra come:

  • Esprimere le classi orientate a oggetti usando i criteri di dati.
  • Implementare tali criteri usando le funzionalità dei criteri di ricerca C#.
  • Sfruttare la diagnostica del compilatore per convalidare l'implementazione.

Prerequisiti

È necessario configurare il computer per l'esecuzione di .NET. Scaricare Visual Studio 2022 o .NET SDK.

Creare una simulazione di una chiusa di canale

In questa esercitazione si creerà una classe C# che simula una chiusa di un canale. In breve, una chiusa è un dispositivo che consente di alzare e abbassare le barche mentre viaggiano tra due tratti d'acqua a livelli diversi. Una chiusa 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 chiusa raggiunge quello del lato da cui entra la barca. Una volta che la barca è nella chiusa, il livello dell'acqua cambia in modo da corrispondere a quello del lato da cui la barca uscirà. Quando i livelli su tale lato corrispondono, il cancello di uscita si apre. Le misure di sicurezza fanno in modo che un operatore non possa creare situazioni di pericolo nel canale. Il livello dell'acqua può essere modificato solo quando entrambi i cancelli sono chiusi. Può essere aperto solo un cancello per volta. Per aprire un cancello, il livello dell'acqua nella chiusa deve corrispondere a quello al di fuori del cancello che viene aperto.

È possibile creare una classe C# per modellare questo comportamento. Una classe CanalLock supporta i comandi per aprire o chiudere uno dei due cancelli. Saranno presenti 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

Si creerà un'applicazione console per testare la classe CanalLock. Creare un nuovo progetto console per .NET 5 usando Visual Studio o l'interfaccia della riga di comando .NET. Aggiungere quindi una classe denominata CanalLock. Progettare poi 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 entrambi i cancelli siano chiusi e il livello dell'acqua sia basso. Scrivere quindi il codice di test seguente nel metodo Main per creare 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 poi una prima implementazione di ogni metodo nella classe CanalLock. Il codice seguente implementa i metodi della classe senza preoccuparsi delle regole di sicurezza. Più avanti si aggiungeranno i test di sicurezza:

// 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 scritti fino a questo momento vengono superati. Sono stati implementati gli elementi essenziali. 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 come 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}");

Questo test ha esito negativo perché il cancello si apre. Come prima implementazione, è possibile correggere il problema 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");
}

Il test viene superato. Tuttavia, man mano che si aggiungono altri test, si aggiungeranno altre clausole if e si testeranno proprietà diverse. Questi metodi diventeranno in breve troppo complessi, man manco che si aggiungono elementi condizionali.

Implementare i comandi con i criteri

Un modo migliore consiste nell'usare i criteri per determinare se l'oggetto è in uno stato valido per eseguire un comando. È possibile esprimere se un comando è consentito in 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 Alta Chiuso
Chiuso Chiuso Basso Chiuso
Chiuso Apertura Alta Chiuso
Chiusi Si apra Basso Chiusi
Apertura Chiusa Alta Apertura
Apertura Chiusa Basso Chiuso (errore)
Apertura Apertura Alta Apertura
Si apra Si apra Basso Chiuso (errore)

La quarta e l'ultima riga della tabella hanno il testo barrato perché non sono valide. Il codice che si sta aggiungendo deve fare in modo che il cancello che separa il lato dove l'acqua è alta non venga mai aperto quando l'acqua dall'altro lato è bassa. Questi stati possono essere codificati come un'unica espressione switch (tenere presente che false indica lo stato chiuso):

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 vengono superati e il codice viene convalidato. La tabella completa mostra le possibili combinazioni di input e i risultati. Esaminando rapidamente la tabella, gli sviluppatori possono notare che sono presenti tutti gli input possibili. Per semplificare ulteriormente il processo può essere di aiuto anche il compilatore. 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 oggetto int. Questa espressione switch controlla solo i valori dichiarati in enum. Per rimuovere l'avviso, è possibile aggiungere un criterio di eliminazione catch-all per l'ultimo elemento dell'espressione. Questa condizione genera un'eccezione perché indica un input non valido:

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

L'elemento switch precedente deve essere l'ultimo nell'espressione switch perché si applica a tutti gli input. Provare a spostarlo più avanti nell'ordine. Verrà generato un errore del compilatore CS8510 che indica che il codice in un criterio non è raggiungibile. La struttura naturale delle espressioni switch consente al compilatore di generare errori e avvisi in caso di possibili problemi. Il meccanismo di sicurezza del compilatore semplifica la creazione di codice corretto in un minor numero di iterazioni, con la libertà di combinare gli elementi switch con caratteri jolly. Il compilatore genera errori se la combinazione genera elementi non raggiungibili e avvisi se si rimuove un elemento necessario.

La prima modifica consiste nel combinare tutti gli elementi in cui il comando è quello di chiudere il cancello, un'operazione sempre consentita. Aggiungere il codice seguente come primo elemento nell'espressione switch:

(false, _, _) => false,

Dopo aver aggiunto l'elemento switch precedente, si otterranno quattro errori del compilatore, uno per ogni elemento in cui il comando è false. Tali elementi sono già coperti da quello appena aggiunto. È possibile rimuovere le quattro righe. Questo nuovo elemento switch sostituisce tali condizioni.

Successivamente, è possibile semplificare i quattro elementi in cui il comando è quello di aprire il cancello. In entrambi i casi in cui il livello dell'acqua è alto, il cancello può essere aperto. In uno dei due casi è già aperto. Un caso in cui il livello dell'acqua è basso genera un'eccezione e l'altro non deve accadere. È possibile generare la stessa eccezione se la chiusa è già in uno stato non valido. È possibile apportare le seguenti semplificazioni per tali elementi:

(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, che verranno superati. 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 autonomamente i criteri

Dopo aver visto la tecnica, compilare autonomamente 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 hanno esito negativo e la chiusa passa a uno stato non valido. Provare a implementare autonomamente i metodi rimanenti. Il metodo per impostare il cancello inferiore deve essere simile a quello per il cancello superiore. Il metodo che modifica il livello dell'acqua ha controlli diversi, ma deve seguire una struttura simile. Può risultare utile usare lo stesso processo per il metodo che imposta il livello dell'acqua. Iniziare con tutti e quattro gli input: lo stato di entrambi i cancelli, 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
};

Saranno presenti 16 elementi in totale da compilare. Quindi, testare e semplificare.

I metodi creati sono simili a quanto segue?

// 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 avere esito positivo e la chiusa deve 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 modifiche a tale stato. È possibile controllare le combinazioni di proprietà. Una volta create le tabelle per una di queste transizioni, testare il codice e quindi semplificarlo per agevolare la leggibilità e la gestione. Questi refactoring iniziali possono suggerire ulteriori refactoring che convalidano lo stato interno o gestiscono altre modifiche delle API. In questa esercitazione sono stati combinati oggetti e classi con un approccio basato su criteri e più orientato ai dati per implementare tali classi.