Использование сопоставления шаблонов для создания поведения класса для улучшения кода
Функции сопоставления шаблонов в C# предоставляют синтаксис для выражения алгоритмов. Эти методы можно использовать для реализации поведения в классах. Вы можете объединить объектно-ориентированное проектирование классов с реализацией, ориентированной на данные, чтобы обеспечить краткий код при моделировании реальных объектов.
В этом руководстве описано, как:
- Экспрессируйте объектно-ориентированные классы с помощью шаблонов данных.
- Реализуйте эти шаблоны с помощью функций сопоставления шаблонов C#.
- Используйте диагностику компилятора для проверки реализации.
Необходимые условия
Необходимо настроить компьютер для запуска .NET. Скачайте Visual Studio 2022 или пакет SDK для .NET.
Создание имитации блокировки канала
В этом руководстве вы создадите класс C#, который имитирует шлюз . Вкратце, шлюз — это устройство, которое поднимает и опускает лодки, когда они перемещаются между двумя участками водоема на разных уровнях. Шлюз содержит два ворота и механизм для изменения уровня воды.
В обычном режиме лодка входит в одни из ворот, в то время как уровень воды в шлюзе соответствует уровню воды со стороны, с которой входит лодка. После блокировки уровень воды изменяется на соответствие уровню воды, где лодка покидает замок. Когда уровень воды соответствует этой стороне, ворота на стороне выхода открываются. Меры безопасности обеспечивают того, что оператор не может создать опасную ситуацию в канале. Уровень воды можно изменить только при закрытии обоих ворот. По крайней мере один ворот можно открыть. Чтобы открыть ворота, уровень воды в замке должен соответствовать уровню воды за пределами ворот, открываемых.
Класс C# можно создать для моделирования этого поведения. Класс CanalLock
поддерживает команды для открытия или закрытия ворот. У нее будут другие команды, чтобы поднять или опустить воду. Класс также должен поддерживать свойства для чтения текущего состояния как затворов, так и уровня воды. Методы реализуют меры безопасности.
Определение класса
Вы создаете консольное приложение для тестирования класса CanalLock
. Создайте проект консоли для .NET 5 с помощью Visual Studio или .NET CLI. Затем добавьте новый класс и назовите его 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
и тестируете различные свойства. Вскоре эти методы будут слишком сложными при добавлении дополнительных условных условий.
Реализация команд с помощью шаблонов
Лучше использовать шаблоны , чтобы определить, находится ли объект в допустимом состоянии для выполнения команды. Можно выразить, разрешена ли команда как функцию трёх переменных: состояния шлюза, уровня воды и новой настройки.
Новый параметр | Состояние шлюза | Уровень воды | Результат |
---|---|---|---|
Закрытый | Закрытый | Высокий | Закрытый |
Закрытый | Закрытый | Низкий | Закрытый |
Закрытый | Откройте | Высокий | Закрытый |
|
|
|
|
Откройте | Закрытый | Высокий | Открыть |
Открыть | Закрытый | Низкий | Закрыто (ошибка) |
Открыть | Откройте | Высокий | Открыть |
|
|
|
|
Четвертая и последняя строки в таблице содержат зачеркнутый текст, поскольку они являются недопустимыми. Добавленный код теперь должен убедиться, что ворота с высокой водой никогда не открываются, когда вода низкая. Эти состояния можно закодировать в виде одного выражения коммутатора (помните, что 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
. Чтобы удалить предупреждение, можно добавить универсальный шаблон для последней ветви выражения. Это условие создает исключение, так как указывает на недопустимые входные данные:
_ => throw new InvalidOperationException("Invalid internal state"),
Предшествующее плечо переключателя должно идти последним в вашем выражении switch
, поскольку оно соответствует всем входным данным. Поэкспериментируйте, переместив его в более раннюю позицию в порядке. Это приводит к ошибке компилятора CS8510 для недоступного кода в шаблоне. Естественная структура выражений коммутатора позволяет компилятору создавать ошибки и предупреждения о возможных ошибках. Компилятор, выступающий в роли "предохранительной сети", упрощает создание правильного кода в меньшем количестве итераций и предоставляет возможность объединения ветвей переключения с подстановочными знаками. Компилятор выдает ошибки, если сочетание приводит к недостижимым ветвям, которых вы не ожидали, и предупреждения, если вы удалите необходимую ветвь.
Первое изменение заключается в том, чтобы объединить все рычаги, где команда - закрыть ворота; это всегда допустимо. Добавьте следующий код в качестве первой ветви в выражении 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}");
Снова запустите приложение. Вы увидите, что новые тесты завершаются сбоем, и блокировка канала попадает в недопустимое состояние. Попробуйте реализовать оставшиеся методы самостоятельно. Метод установки нижних врат должен быть аналогичен методу установки верхних врат. Метод, который изменяет уровень воды, имеет различные проверки, но должен соответствовать аналогичной структуре. Возможно, полезно использовать тот же процесс для метода, который задает уровень воды. Начните со всех четырех входных данных: состояние обоих ворот, текущее состояние уровня воды и запрошенный новый уровень воды. Выражение переключателя должно начинаться с:
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 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"),
};
}
Ваши тесты должны успешно завершиться, и канальный шлюз должен безопасно работать.
Сводка
В этом руководстве вы узнали, как использовать сопоставление шаблонов для проверки внутреннего состояния объекта перед применением изменений к этому состоянию. Можно проверить сочетания свойств. После того как вы создадите таблицы для любого из этих переходов, протестируйте код, а затем упростите его для удобочитаемости и поддерживаемости. Эти первоначальные изменения кода могут предложить дополнительные изменения кода, которые проверяют внутреннее состояние или управляют другими изменениями в API. В этом руководстве объединяются классы и объекты с более ориентированным на данные подходом на основе шаблонов для реализации этих классов.