OneOf для C# (.NET): полное руководство с примерами
Библиотека OneOf — это реализация discriminated unions (размеченных объединений) для языка C#. Она решает фундаментальную проблему: как в статически типизированном языке элегантно представить значение, которое может быть одним из нескольких заранее известных типов. Вместо того чтобы использовать object с приведением типов, Nullable или создавать иерархии классов, OneOf предоставляет типобезопасный и выразительный способ работы с такими значениями.
Зачем это нужно? Во-первых, это повышает читаемость кода: сигнатура метода, возвращающего OneOf<Success, Error>, сразу говорит о том, что операция может завершиться успехом или ошибкой. Во-вторых, это устраняет целый класс ошибок, связанных с неправильным приведением типов. В-третьих, OneOf вынуждает разработчика обрабатывать все возможные варианты, что делает код более надёжным и предсказуемым. Это особенно полезно в сценариях, где традиционно используются исключения (для потока управления) или возврат null.
OneOf идеально вписывается в функциональный стиль программирования на C#. Он позволяет моделировать предметную область точнее: вместо того чтобы иметь класс Result с флагами и вложенными объектами, вы можете использовать OneOf<T0, T1, T2>, где каждый тип представляет одно из возможных состояний. Это делает код более декларативным и уменьшает количество if-else конструкций.
Установка
Установка библиотеки производится через NuGet Package Manager. Выполните одну из следующих команд в зависимости от вашего инструмента:
# Package Manager Console
Install-Package OneOf
# .NET CLI
dotnet add package OneOf
# PackageReference (вручную в .csproj)
<PackageReference Include="OneOf" Version="3.0.271" />
После установки добавьте using OneOf; в начало вашего файла.
Быстрый старт
Рассмотрим простейший пример: функция, которая делит одно число на другое. Вместо того чтобы выбрасывать исключение при делении на ноль, мы вернём OneOf<double, string> — либо результат, либо сообщение об ошибке.
using OneOf;
public OneOf<double, string> SafeDivide(double a, double b)
{
if (b == 0)
return "Ошибка: деление на ноль";
return a / b;
}
// Использование
var result = SafeDivide(10, 2);
result.Switch(
value => Console.WriteLine($"Результат: {value}"),
error => Console.WriteLine($"Ошибка: {error}")
);
// Вывод: Результат: 5
Обратите внимание: мы не используем out-параметры, не проверяем null и не ловим исключения. Всё, что нам нужно — это вызвать Switch или Match и предоставить обработчики для каждого возможного варианта.
Основные методы и классы
Библиотека предоставляет несколько ключевых механизмов для работы с размеченными объединениями. Рассмотрим каждый из них подробно.
1. OneOf<T0, T1, ...>
Сигнатура: public class OneOf<T0, T1, T2, T3, T4, T5, T6, T7, T8, T9> : IOneOf
Что делает: Базовый класс, представляющий значение, которое может быть одним из указанных типов. Поддерживает до 9 типов. Неявное преобразование из любого из указанных типов в OneOf.
OneOf<int, string, bool> value = 42; // неявное преобразование
value = "hello";
value = true;
// Явное создание
var explicitValue = new OneOf<int, string>(100);
2. Switch
Сигнатура: public void Switch(Action<T0> f0, Action<T1> f1, ...)
Что делает: Выполняет одно из переданных действий в зависимости от того, какое значение хранится в OneOf. Ничего не возвращает.
OneOf<int, string> data = "test";
data.Switch(
intValue => Console.WriteLine($"Число: {intValue}"),
strValue => Console.WriteLine($"Строка: {strValue}")
);
// Вывод: Строка: test
3. Match
Сигнатура: public TResult Match<TResult>(Func<T0, TResult> f0, Func<T1, TResult> f1, ...)
Что делает: Аналог Switch, но возвращает результат типа TResult. Позволяет трансформировать значение в другой тип.
OneOf<int, string> data = 42;
string description = data.Match(
intValue => $"Это число: {intValue}",
strValue => $"Это строка: {strValue}"
);
Console.WriteLine(description);
// Вывод: Это число: 42
4. TryPick
Сигнатура: public bool TryPick<T>(out T value)
Что делает: Пытается извлечь значение указанного типа. Возвращает true, если тип совпадает, иначе false. Полезно, когда нужно проверить конкретный тип без полного паттерн-матчинга.
OneOf<int, string> data = "hello";
if (data.TryPick(out string strValue))
{
Console.WriteLine($"Извлечена строка: {strValue}");
}
else
{
Console.WriteLine("Значение не является строкой");
}
// Вывод: Извлечена строка: hello
5. IsT0, IsT1, ...
Сигнатура: public bool IsT0 { get; }
Что делает: Свойства, которые возвращают true, если OneOf содержит значение соответствующего типа (T0, T1, ...). Позволяет быстро проверить тип.
OneOf<int, string> data = 100;
if (data.IsT0) // проверяем, является ли значением int (первый тип)
{
Console.WriteLine("Это int");
}
// Вывод: Это int
6. AsT0, AsT1, ...
Сигнатура: public T0 AsT0 { get; }
Что делает: Свойства, которые извлекают значение, если оно соответствует типу. Выбрасывает InvalidOperationException, если тип не совпадает. Используйте с осторожностью, предварительно проверив тип через IsT0.
OneOf<int, string> data = 42;
if (data.IsT0)
{
int number = data.AsT0;
Console.WriteLine(number * 2);
}
// Вывод: 84
7. Value
Сигнатура: public object Value { get; }
Что делает: Возвращает хранимое значение как object. Полезно для отладки или когда нужно передать значение в код, не знающий о типах. Потеря типобезопасности.
OneOf<int, string> data = "test";
object raw = data.Value;
Console.WriteLine(raw.GetType()); // System.String
8. ToString
Сигнатура: public override string ToString()
Что делает: Возвращает строковое представление хранимого значения. Вызывает ToString() у внутреннего объекта.
OneOf<int, string> data = 123;
Console.WriteLine(data.ToString()); // 123
data = "abc";
Console.WriteLine(data.ToString()); // abc
9. Equals и GetHashCode
Сигнатура: public override bool Equals(object obj) и public override int GetHashCode()
Что делает: Реализуют сравнение по значению. Два OneOf считаются равными, если они содержат значение одного и того же типа и эти значения равны.
var a = new OneOf<int, string>(10);
var b = new OneOf<int, string>(10);
Console.WriteLine(a.Equals(b)); // True
var c = new OneOf<int, string>("10");
Console.WriteLine(a.Equals(c)); // False (разные типы)
10. Map
Сигнатура: public OneOf<TResult0, TResult1> Map<TResult0, TResult1>(Func<T0, TResult0> map0, Func<T1, TResult1> map1)
Что делает: Трансформирует каждый возможный тип в новый тип, возвращая новый OneOf. Позволяет "отобразить" один discriminated union на другой.
OneOf<int, string> input = 42;
var mapped = input.Map(
intValue => intValue.ToString(),
strValue => strValue.Length
);
// mapped теперь OneOf<string, int>
mapped.Switch(
s => Console.WriteLine($"Строка: {s}"),
i => Console.WriteLine($"Длина: {i}")
);
// Вывод: Строка: 42
Реальный практический пример
Рассмотрим более сложный пример: система обработки заказов в интернет-магазине. У нас есть три возможных результата обработки: заказ успешно создан, недостаточно средств на счету, или товара нет на складе. Мы смоделируем это с помощью OneOf.
using OneOf;
// Типы для представления результатов
public record OrderCreated(int OrderId, decimal TotalAmount);
public record InsufficientFunds(decimal Balance, decimal Required);
public record OutOfStock(string ProductName, int AvailableQuantity);
// Наш discriminated union
public class OrderResult : OneOf<OrderCreated, InsufficientFunds, OutOfStock>
{
public OrderResult(OneOf<OrderCreated, InsufficientFunds, OutOfStock> input) : base(input) { }
// Неявные преобразования для удобства
public static implicit operator OrderResult(OrderCreated value) => new OrderResult(value);
public static implicit operator OrderResult(InsufficientFunds value) => new OrderResult(value);
public static implicit operator OrderResult(OutOfStock value) => new OrderResult(value);
}
// Класс, имитирующий работу с заказами
public class OrderService
{
private readonly Dictionary<string, int> _stock = new()
{
{ "Laptop", 5 },
{ "Mouse", 0 },
{ "Keyboard", 10 }
};
private decimal _userBalance = 1500;
public OrderResult ProcessOrder(string productName, int quantity, decimal price)
{
// Проверка наличия на складе
if (!_stock.ContainsKey(productName) || _stock[productName] < quantity)
{
int available = _stock.GetValueOrDefault(productName, 0);
return new OutOfStock(productName, available);
}
// Проверка баланса
decimal total = price * quantity;
if (_userBalance < total)
{
return new InsufficientFunds(_userBalance, total);
}
// Успешное создание заказа
_userBalance -= total;
_stock[productName] -= quantity;
return new OrderCreated(new Random().Next(1000, 9999), total);
}
}
// Использование в реальном коде
public class Program
{
public static void Main()
{
var service = new OrderService();
// Тест 1: Успешный заказ
var result1 = service.ProcessOrder("Laptop", 1, 1200);
ProcessResult(result1);
// Тест 2: Недостаточно средств
var result2 = service.ProcessOrder("Keyboard", 5, 100);
ProcessResult(result2);
// Тест 3: Нет на складе
var result3 = service.ProcessOrder("Mouse", 1, 50);
ProcessResult(result3);
}
static void ProcessResult(OrderResult result)
{
// Используем Match для получения строкового описания
string message = result.Match(
created => $"Заказ #{created.OrderId} создан на сумму {created.TotalAmount:C}",
funds => $"Недостаточно средств. Баланс: {funds.Balance:C}, требуется: {funds.Required:C}",
stock => $"Товар '{stock.ProductName}' отсутствует. Доступно: {stock.AvailableQuantity} шт."
);
Console.WriteLine(message);
// Альтернатива: используем Switch для выполнения действий
result.Switch(
created => SendConfirmationEmail(created.OrderId),
funds => LogInsufficientFunds(funds),
stock => NotifyManager(stock)
);
}
static void SendConfirmationEmail(int orderId)
=> Console.WriteLine($" [EMAIL] Подтверждение заказа #{orderId} отправлено");
static void LogInsufficientFunds(InsufficientFunds funds)
=> Console.WriteLine($" [LOG] Недостаточно средств: {funds.Required:C}");
static void NotifyManager(OutOfStock stock)
=> Console.WriteLine($" [ALERT] Менеджеру: пополните склад {stock.ProductName}");
}
Вывод программы:
Заказ #4521 создан на сумму 1 200,00 ₽
[EMAIL] Подтверждение заказа #4521 отправлено
Недостаточно средств. Баланс: 300,00 ₽, требуется: 500,00 ₽
[LOG] Недостаточно средств: 500,00 ₽
Товар 'Mouse' отсутствует. Доступно: 0 шт.
[ALERT] Менеджеру: пополните склад Mouse
Обратите внимание: мы создали класс-наследник Order