В чем разница между модификаторами in и out в C# (в контексте обобщенных типов)?

Ответ

Модификаторы in и out в C# используются для объявления вариантности (variance) в универсальных (обобщенных) интерфейсах и делегатах. Они позволяют обеспечить безопасное приведение обобщенного типа с одним параметром типа к обобщенному типу с другим параметром типа.

  • Ковариантность (out): Позволяет использовать более производный тип, чем заданный изначально. Ключевое слово out указывает, что параметр типа используется только как возвращаемое значение (выходной параметр).

    // Объявление ковариантного интерфейса
    interface IProducer<out T>
    {
        T Produce(); // T используется только на выходе
    }
    
    class Animal { }
    class Dog : Animal { }
    
    IProducer<Dog> dogProducer = ...;
    // Безопасное приведение: IProducer<Dog> -> IProducer<Animal>
    IProducer<Animal> animalProducer = dogProducer; // Ковариантность разрешена благодаря `out`
    Animal a = animalProducer.Produce(); // Вернет Dog, который является Animal

    Пример в .NET: IEnumerable<out T>.

  • Контравариантность (in): Позволяет использовать более универсальный (базовый) тип, чем заданный изначально. Ключевое слово in указывает, что параметр типа используется только как входной параметр.

    // Объявление контравариантного интерфейса
    interface IConsumer<in T>
    {
        void Consume(T item); // T используется только на входе
    }
    
    IConsumer<Animal> animalConsumer = ...;
    // Безопасное приведение: IConsumer<Animal> -> IConsumer<Dog>
    IConsumer<Dog> dogConsumer = animalConsumer; // Контравариантность разрешена благодаря `in`
    dogConsumer.Consume(new Dog()); // Метод ожидает Animal, а получает Dog (это безопасно)

    Пример в .NET: Action<in T>.

Правила и почему это безопасно:

  1. out (ковариантность): Гарантирует, что T будет использоваться только для возврата значений. Вы не сможете объявить метод, принимающий T как параметр. Это безопасно, потому что потребитель интерфейса IProducer<Animal> ожидает получить Animal, а реализация IProducer<Dog> всегда вернет Dog, который является подтипом Animal.
  2. in (контравариантность): Гарантирует, что T будет использоваться только как входной параметр. Вы не сможете объявить метод, возвращающий T. Это безопасно, потому что потребитель интерфейса IConsumer<Dog> будет передавать в метод Consume аргумент типа Dog, а реализация IConsumer<Animal> готова принять любой Animal, включая Dog.

Итог: out позволяет присваивать коллекции производных типов коллекциям базовых типов (можно читать). in позволяет присваивать делегаты/обработчики, работающие с базовыми типами, делегатам, работающим с производными типами (можно писать). Без этих модификаторов обобщенные типы являются инвариантными.

Ответ 18+ 🔞

А, вот эти ваши модификаторы in и out в дженериках! Ну, это ж классика, блядь. Сидишь такой, пишешь код, и тут бац — тебе нужно IEnumerable<Dog> запихнуть туда, где ждут IEnumerable<Animal>. А компилятор тебе: «Не-а, мудила, нельзя, типы разные». И стоишь ты, как дурак, с этими собаками и животными, и думаешь: «Да они же, сука, наследуются!».

Так вот, чтобы не выглядеть идиотом, и придумали ковариантность (out) и контравариантность (in). Это не магия, а просто способ сказать компилятору: «Расслабься, я знаю, что делаю, здесь всё безопасно».

Короче, out — это когда ты только получаешь (как будто «выводишь»).

Смотри, объявляешь интерфейс:

interface IProducer<out T> // Видишь `out`? Это ключ!
{
    T GiveMeSomething(); // T используется ТОЛЬКО чтобы вернуть. Никаких входных параметров с T!
}

И теперь, если у тебя есть IProducer<Dog>, ты его спокойно можешь присвоить в IProducer<Animal>. Почему? Да потому что если эта хрень обещает вернуть Dog, то для того, кто ждёт Animal, это вообще шикарно — Dog ведь частный случай Animal. Всё логично. В .NET так устроен, например, IEnumerable<out T> — его можно только читать, поэтому он ковариантный.

А in — это полная противоположность, когда ты только отдаёшь (как будто «вводишь»).

interface IConsumer<in T> // А вот и `in`!
{
    void TakeThis(T item); // T используется ТОЛЬКО как вход. Никаких возвратов T!
}

И тут магия в другую сторону. Если у тебя есть IConsumer<Animal>, ты его можешь присвоить в IConsumer<Dog>. Звучит как бред? А вот и нет. Потому что если твой обработчик умеет работать с любым Animal, то с конкретным Dog он уж точно справится, ему не привыкать. Классический пример — делегат Action<in T>.

А теперь самое важное, почему это не ерунда, а гениально и безопасно:

Компилятор — он не дурак, он следит за тобой, как ястреб. Если ты пометил параметр как out, то ты не сможешь объявить метод, который принимает этот T как аргумент. Только возвращать. И наоборот, с in — нельзя вернуть T, только принять. Это чтобы ты, балда, случайно не выстрелил себе в ногу, пытаясь запихнуть кота в интерфейс для собак.

Итог простой, как три копейки:

  • out (читай «отсюда») — позволяет использовать более конкретный тип вместо более общего. Что-то<Сын> превращается в Что-то<Отец>. Можно получать.
  • in (читай «сюда») — позволяет использовать более общий тип вместо более конкретного. Что-то<Отец> превращается в Что-то<Сын>. Можно передавать.

А если эти модификаторы не указать, то дженерик будет инвариантным — то есть, List<Dog> и List<Animal> это два абсолютно разных, несовместимых типа, хоть ты тресни. Вот такая, блядь, строгость.