OneOf для C# (.NET): полное руководство с примерами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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

Рекомендаций