Использование сопоставления шаблонов для создания поведения класса и улучшения кода

Функции сопоставления шаблонов в C# предоставляют синтаксис для выражения алгоритмов. Эти методы можно использовать для реализации поведения в классах. Объектно-ориентированную архитектуру класса можно объединить с реализацией, ориентированной на данные, чтобы обеспечить лаконичный код при моделировании реальных объектов.

Из этого руководства вы узнаете, как выполнять следующие задачи:

  • Выражать объектно-ориентированные классы с помощью шаблонов данных.
  • Внедрять эти шаблоны, используя функции сопоставления шаблонов на C#.
  • Использовать диагностику компилятора для проверки реализации.

Необходимые компоненты

Вам потребуется настроить компьютер для запуска .NET. Скачайте Visual Studio 2022 или пакет SDK для .NET.

Создание симуляции шлюза канала

В этом руководстве вы создадите класс C#, который симулирует шлюз канала. Шлюз — это устройство, которое поднимает и опускает судна, которые перемещаются между двумя водными пространствами на разных уровнях. Шлюз включает двое ворот и механизм для изменения уровня воды.

При эксплуатации в обычных условиях судно входит в одни из ворот, при этом уровень воды в шлюзе соответствует уровню воды на стороне входа судна. Когда судно находится в шлюзовой камере, уровень воды изменяется, чтобы соответствовать уровню воды камеры, из которой судно будет выходить. Как только уровень воды соответствует уровню воды камеры выхода, в камере выхода открываются ворота. Меры безопасности настроены таким образом, чтобы оператор не мог создать опасную ситуацию в шлюзе. Уровень воды изменяется только при закрытых воротах. Можно открыть только одни ворота. Чтобы открыть ворота, внутренний уровень воды в шлюзе должен соответствовать уровню воды на выходе.

Для моделирования такого поведения можно создать класс C#. Класс CanalLock будет поддерживать команды для открытия или закрытия обоих ворот. А также иметь разные команды для увеличения или уменьшения уровня воды. Класс также должен поддерживать свойства для чтения текущего состояния ворот и уровня воды. Ваши методы будут реализовать меры безопасности.

Определение класса

Вам нужно будет создать консольное приложение для тестирования класса CanalLock. Создайте новый консольный проект для .NET 5 с помощью Visual Studio или интерфейса командной строки .NET. Затем добавьте новый класс и назовите его CanalLock. Теперь создайте общедоступный API, однако не реализуйте методы:

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}.";
}

Приведенный выше код инициализирует объект таким образом, чтобы и те и другие ворота были закрыты, а уровень воды оставался низким. Далее напишите следующий код проверки в методе Main, который поможет вам создать первую реализацию класса:

// 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}");

Затем добавьте первую реализацию каждого метода в класс CanalLock. Следующий код реализует методы класса, не влияя на правила безопасности. Испытания на безопасность будут добавлены позже:

// 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;
}

Тесты, которые вы написали, прошли успешно. Вы реализовали основы. Теперь напишите тест для условия независимого отказа. По окончании предыдущих тестов одни и другие ворота закрыты, а уровень воды — низкий. Добавьте тест для открытия верхних ворот:

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}");

Тест завершается сбоем, поскольку ворота открываются. Для первой реализации внесите исправления, используя следующий код:

// 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");
}

Тесты успешно пройдены. При добавлении дополнительных тестов вы добавляете дополнительные if предложений и проверяете различные свойства. При добавлении дополнительных условий со временем методы становятся слишком сложными.

Реализация команд с помощью шаблонов

Лучшим решением станет использование шаблонов, чтобы определить, находится ли объект в допустимом состоянии для выполнения команды. Вы можете выразить разрешение на выполнение команды в качестве функции с тремя переменными: состояние ворот, уровень воды и новый параметр.

Новый параметр Состояние ворот Уровень воды Результат
Закрыт Закрыт Высокая Закрыт
Закрыт Закрыт Низкая Закрыт
Закрыт При открытии Высокая Закрыт
Закрытые Открыть Низкая Закрытые
При открытии Закрытые Высокая При открытии
При открытии Закрытые Низкая Закрыто (ошибка)
При открытии При открытии Высокая При открытии
Открыть Открыть Низкая Закрыто (ошибка)

Текст четвертой и последней строки в таблице зачеркнут, поскольку они недопустимы. Добавленный код должен гарантировать, что верхние ворота никогда не откроются при низком уровне воды. Эти состояния можно запрограммировать как одиночное выражение switch (помните, что false означает "Закрыто"):

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
};

Попробуйте эту версию. Тесты пройдены, проверка кода. В полной таблице показаны возможные комбинации входных и выходных значений. Это означает, что вы и другие разработчики можете взглянуть на таблицу и проверить охват всех возможных входных данных. И даже проще — вам в этом может помочь компилятор. После добавления предыдущего кода можно увидеть, что компилятор создает предупреждение: CS8524 указывает, что выражение коммутатора не охватывает все возможные входные данные. Причиной предупреждения является то, что некоторые входные данные имеют тип enum. Компилятор интерпретирует "все возможные входные данные" как все входные данные из базового типа, обычно это — int. Это выражение switch проверяет только значения, указанные в enum. Для устранения предупреждения можно добавить шаблон пустой переменной catch-all для последней ветви выражения. Это условие создает исключение, поскольку указывает на недопустимые входные данные:

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

Предыдущая ветвь switch должна быть последней в выражении switch, так как она соответствует всем входным данным. Поэкспериментируйте, изменяя порядок. Это вызывает ошибку компилятора CS8510 — недопустимый код в шаблоне. Естественная структура выражений switch позволяет компилятору создавать ошибки и предупреждения для потенциальных ошибок. Компилятор "безопасная сеть" упрощает создание чистого кода с меньшим количеством итераций, а также позволяет объединять ветви переключения с подстановочными знаками. Компилятор выдаст ошибки, если комбинация приведет к недопустимым ветвям, а также к предупреждениям при удалении необходимой ветви.

Первое изменение состоит в том, чтобы объединить все ветви, в которых командой является закрытие ворот; оно всегда разрешено. Добавьте следующий код в виде первой ветви в выражении switch.

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

Запустите тесты еще раз, они должны пройти успешно. Финальная версия метода 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"),
    };
}

Самостоятельное внедрение шаблонов

Теперь, когда вы знакомы с методикой, заполните методы SetLowGate и SetWaterLevel самостоятельно. Начните с добавления следующего кода для проверки недопустимых операций для этих методов:

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}");

Запустите приложение еще раз. Видно, что новые тесты завершаются ошибкой, а шлюз переходит в недопустимое состояние. Попробуйте реализовать оставшиеся методы самостоятельно. Метод для нижних ворот должен быть идентичен методу для верхних ворот. Метод для изменения уровня воды имеет другие проверки, однако должен иметь ту же структуру. Вы можете использовать тот же процесс для метода, который задает уровень воды. Начните со всех четырех входных данных: состояние обоих ворот, текущее состояние уровня воды и запрошенный новый уровень воды. Выражение switch должно начинаться с:

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

Вы получите 16 ветвей переключения, которые нужно заполнить данными. Проверьте и упростите их.

Получилось ли у вас что-то наподобие этого?

// 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"),
    };
}

Тесты должны успешно завершиться, а шлюз — работать безопасно.

Итоги

Из этого руководства вы узнали, как использовать сопоставление шаблонов для проверки внутреннего состояния объекта, прежде чем изменять состояние. Вы можете проверять сочетания свойств. После создания таблиц для любого из этих переходов необходимо протестировать код, а затем упростить его для более удобного чтения и сопровождения. Эти изначальные рефакторинги могут рекомендовать другие рефакторинги, которые проверяют внутреннее состояние или управляют другими изменениями API. В этом учебнике были объединены классы и объекты с более ориентированным на данные подходом, в котором для реализации этих классов используются шаблоны.