The art of Generics

    Универсальные шаблоны – они же generics, являются одним из мощнейших инструментов разработки.

    CLR поддерживает их на уровне MSIL, и всего рантайма, что позволяет нам совершать некоторые трюки с типобезопасностью.

    Если Вы знакомы с шаблонами C++, но хотели бы провернуть, если не вычисления на этапе компиляции, то по изяществу ничем не уступающие операции на C#, то эта статья поможет в этом.

    Немного о паттернах


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

    [Примечание: нижеприведенные примеры не имеют отношения конкретно к «трюкам» с generics, являющимся основной целью статьи. Автору лишь хочется показать ход мыслей.]

    Чего только стоит MVC. Где для обработки логики на стороне контроллера можно использовать стратегию, а лучше вместе с фабричным методом (не путать с абстрактной фабрикой).

    Лучше, чем GoF, описать их не получится, поэтому двинемся далее.

    Существуют такие паттерны как:
    • Multiple dispatch
    • Double dispatch (он же вид паттерна Visitor)

    Суть первого заключается в расширении single dispatch – она перегрузка по типу объекта.

    Например, начиная с C# 4 и его dynamic можно легко показать на примере из wikipedia.

    Muliple dispatch
    class Program
    {
        class Thing { }
        class Asteroid : Thing { }
        class Spaceship : Thing { }
    
        static void CollideWithImpl(Asteroid x, Asteroid y)
        {
            Console.WriteLine("Asteroid collides with Asteroid");
        }
    
        static void CollideWithImpl(Asteroid x, Spaceship y)
        {
            Console.WriteLine("Asteroid collides with Spaceship");
        }
    
        static void CollideWithImpl(Spaceship x, Asteroid y)
        {
            Console.WriteLine("Spaceship collides with Asteroid");
        }
    
        static void CollideWithImpl(Spaceship x, Spaceship y)
        {
            Console.WriteLine("Spaceship collides with Spaceship");
        }
    
        static void CollideWith(Thing x, Thing y)
        {
            dynamic a = x;
            dynamic b = y;
            CollideWithImpl(a, b);
        }
    
        static void Main(string[] args)
        {
            var asteroid = new Asteroid();
            var spaceship = new Spaceship();
            CollideWith(asteroid, spaceship);
            CollideWith(spaceship, spaceship);
        }
    }
    


    Как видно простой перегрузки метода не хватило бы для реализации данного паттерна.
    Но перейдем теперь к Double dispatch. Перепишем пример таким образом:

    Double dispatch
    class Program
    {
        interface ICollidable
        {
            void CollideWith(ICollidable other);
        }
    
        class Asteroid : ICollidable
        {
            public void CollideWith(Asteroid other)
            {
                Console.WriteLine("Asteroid collides with Asteroid");
            }
    
            public void CollideWith(Spaceship spaceship)
            {
                Console.WriteLine("Asteroid collides with Spaceship");
            }
    
            public void CollideWith(ICollidable other)
            {
                other.CollideWith(this);
            }
        }
    
        class Spaceship : ICollidable
        {
            public void CollideWith(ICollidable other)
            {
                other.CollideWith(this);
            }
    
            public void CollideWith(Asteroid asteroid)
            {
                Console.WriteLine("Spaceship collides with Asteroid");
            }
    
            public void CollideWith(Spaceship spaceship)
            {
                Console.WriteLine("Spaceship collides with Spaceship");
            }
        }
    
        static void Main(string[] args)
        {
            var asteroid = new Asteroid();
            var spaceship = new Spaceship();
            asteroid.CollideWith(spaceship);
            asteroid.CollideWith(asteroid);
        }
    }
    


    Что же, как видно можно обойтись и без dynamic.

    Так к чему все это?

    Ответ прост – если мы можем расширять одинарную диспетчеризацию (single dispatch), что есть перегрузка по типу объекта, переходя к случаю перегрузке по нескольким объектам (multiple dispatch), то почему не сделать такое и с generics?!

    Covariance && Contravariance


    Вообще, ковариантность типов в любом языке программирования кажется само собой разумеющимся. Например:

    var asteroid = new Asteroid();
     
    ICollidable collidable = asteroid;
    

    Однако это называется совместимость назначения (assignment compatibility).

    Ковариантность проявляется именно при работе с generics.

    List<Asteroid> asteroids = new List<Asteroid>();
     
    IEnumerable<ICollidable> collidables = asteroids;
    

    Декларация IEnumerable выглядит следующим образом:

    public interface IEnumerable<out T> : IEnumerable
    {
      IEnumerator<T> GetEnumerator();
    }
    

    При отсутствии ключевого слова out и поддержке ковариантности невозможно было бы привести тип List<Asteroid> к типу IEnumerable<ICollidable>, несмотря на имплементацию данного интерфейса классом List<T>.

    Наверное, Вы уже знаете, что типы помеченные как out T нельзя использовать как параметры методов, даже в виде типизированного аргумента к другому классу или интерфейсу. Например:

    interface ICustomInterface<out T>
    {
        T Do(T target); //compile-time error
        T Do(IList<T> targets); //compile-time error
    }
    

    Что же, возьмем эту особенность на заметку, а пока перейдем к нашей цели – расширим возможность перегрузки по generics.

    Generics compile-time checking


    Рассмотрим следующий интерфейс:

    public interface IReader<T>
    {
        T Read(T[] arr, int index);
    }
    

    Ничего необычного на первый взгляд. Однако, как реализовать имплементацию лишь для чисел или чисел с плавающей запятой? Т.е. ввести ограничение на тип во время компиляции?

    C# не предоставляет такую возможность. Можно лишь обозначить как struct, class или конкретный тип (еще есть new()) для типизированного параметра.

    public interface IReader<T> where T : class
    {
        T Read(T[] arr, int index);
    }
    

    Помните пример с астероидами для multiple dispatch?

    Точно такое же мы применим для имплементации IReader.

    public class SignedIntegersReader : IReader<Int32>, IReader<Int16>, IReader<Int64>
    {
        int IReader<int>.Read(int[] arr, int index)
        {
            return arr[index];
        }
    
        short IReader<short>.Read(short[] arr, int index)
        {
            return arr[index];
        }
    
        long IReader<long>.Read(long[] arr, int index)
        {
            return arr[index];
        }
    }
    

    Думаю, возникает вопрос – почему именно явная (explicit) имплементация интерфейса?

    Все дело именно в поддержке ковариантности для любого метода интерфейса.

    Так, ковариантные интерфейсы не могут содержать в методах параметры с типом T, даже, например, IList.

    А так как в C# поддержка перегрузки методов по возвращаемому типу невозможна, соответственно множественная неявная имплементация интерфейса с методами, где количество аргументов больше и равно нулю не будет компилироваться.

    Что же, осталось использовать данные возможности на практике.

    public static class ReaderExtensions
    {
        public static T Read<TReader, T>(this TReader reader, T[] arr, int index) 
                                                                where TReader : IReader<T>
        {
            return reader.Read(arr, index);
        }
    }
    
    class Program
    {
        static void Main(string[] args)
        {
            var reader = new SignedIntegersReader();
    
            var arr = new int[] {128, 256};
    
            for (int i = 0; i < arr.Length; i++)
            {
                Console.WriteLine("Reader result: {0}", reader.Read(arr, i));
            }
        }
    }
    

    Попробуем изменить тип переменной arr на float[].

    class Program
    {
        static void Main(string[] args)
        {
            var reader = new SignedIntegersReader();
    
            var arr = new float[] {128.0f, 256.0f};
    
            for (int i = 0; i < arr.Length; i++)
            {
                Console.WriteLine("Reader result: {0}", reader.Read(arr, i)); //compile-time error
            }
        }
    }
    

    Но это же достигается лишь через методы расширения?! Как быть если необходимо именно реализация интерфейса?

    Немного видоизменим наш интерфейс IReader.

    IReader<T>
    public interface IReader<T>
    {
        T Read(T[] arr, int index);
        bool Supports<TType>();
    }
     
    public class SignedIntegersReader : IReader<Int32>, IReader<Int16>, IReader<Int64>
    {
        int IReader<int>.Read(int[] arr, int index)
        {
            return arr[index];
        }
    
        short IReader<short>.Read(short[] arr, int index)
        {
            return arr[index];
        }
    
        long IReader<long>.Read(long[] arr, int index)
        {
            return arr[index];
        }
    
        public bool Supports<TType>()
        {
            return this as IReader<TType> != null;
        }
    }
    


    И добавим еще одну реализацию IReader — DefaultReader.

    public class DefaultReader<T> : IReader<T>
    {
        private IReader<T> _reader = new SignedIntegersReader() as IReader<T>;
    
        public T Read(T[] arr, int index)
        {
            if (_reader != null)
            {
                return _reader.Read(arr, index);
            }
            return default(T);
        }
    
        public bool Supports<TType>()
        {
            return _reader.Supports<TType>();
        }
    }
    

    Проверим на практике:

    class Program
    {
        static void Main(string[] args)
        {
            var reader = new DefaultReader<int>();
     
            var arr = new int[] { 128, 256 };
     
            if (reader.Supports<int>())
            {
                for (int i = 0; i < arr.Length; i++)
                {
                    Console.WriteLine("Reader result: {0}", reader.Read(arr, i));
                }
            }
        }
    }
    

    Таким образом, мы получили две реализации задачи проверки перегрузки по параметризированным типам – как во время компиляции, так и выполнения.
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 16
    • +1
      Всё-таки иногда не хватает мощности констрейнтов генериков в сишарпе, хотелось бы на Enum (хотя clr поддерживает такое), Числа, операции +\- и т.д.
      • –3
        Универсальные шаблоны – они же generics

        Первая фраза и тут же обрадовали :) Шаблоны != Generics, об этом говориться в официальной документации даже:
        blogs.msdn.com/b/ericlippert/archive/2009/07/30/generics-are-not-templates.aspx
        msdn.microsoft.com/en-us/library/vstudio/sbh15dya.aspx
        • +2
          Вы не правильно поняли контекст предложения, более того — официальный русский перевод слова genericsУниверсальные шаблоны

          А насчет, того что generics != templates, то шаблоны в C++ — это макросы, «выглядящие» как классы, а generics — это параметричесике типы.
          • +1
            Всегда считал, что официальный перевод (according to msdn) — Обобщения.

            Ок, не совсем прав.
            • +1
              Не нашёл выше по ссылке
              официальный русский перевод слова generics

              Зато указанная статья встречает плашкой:
              Данная статья переведена автоматически. Наведите указатель мыши на предложения статьи, чтобы просмотреть исходный текст. Дополнительные сведения.

              Да и дилетантский способ проверить популярность того или иного термина показывает:
              c# обобщения — 222 тыс. результатов;
              c# универсальные шаблоны — 6,5 тыс. результатов.
              • 0
                Я не стал бы использовать термин «универсальный шаблон» как минимум потому, что у нас шаблоны уже есть, это templates. А как автор правильно сказал, generics ≠ templates. Поэтому generics, мне кажется, лучше всё-таки называть «обобщениями» или же «параметризованными типами».
            • 0
              Шаблоны в С++ — это не макросы. Хотя бы потому, что они реализуют полный по тьюрингу чистый функциональный язык оперирующий с типами.
              • 0
                ну это ни в коем случае не доказывает обратное.

                более того, я писал:

                >>это макросы, «выглядящие» как классы

                какую абстракцию иметь ввиду, дело другое
                • 0
                  Если бы это были макросы, то шаблоны можно было бы «раскрыть» преобразовав программу к семантически эквивалентному виду, но без макросов. Однако в общем случае это невозможно, потому что в С++ есть SFINAE.

                  Например, у вас никак не получится реализовать на макросах вот такой код:
                  #include <type_traits>
                  #include <iostream>
                  #include <string>
                   
                  struct NonPod { virtual ~NonPod() {} };
                  struct Pod {};
                   
                  struct classifier
                  {
                      template<class T> 
                      static std::string classify(&&rr, ...) 
                      { 
                          return "Classified as non POD"; 
                      }
                   
                      template<class T>
                      static std::string classify(&&rr, typename std::enable_if<std::is_pod<T>::value>::type * = 0) 
                      { 
                          return "Classified as POD"; 
                      }
                  };
                   
                  void main()
                  {
                      std::cout << classifier::classify(NonPod()) << std::endl; // prints "Classified as non POD"
                      std::cout << classifier::classify(Pod()) << std::endl; // prints "Classified as POD"
                  }

                  Вы просто не сможете определить какую перегрузку выбрать, т.к. используя только макросы вы не сможете определить, является ли тип Т pod-типом — для этого нужен полноценный компилятор. И это только частный случай :)
          • 0
            Я что-то не понял зачем в Double Dispatch примере интерфейсы, ведь через них пользоваться этим кодом не получится. Может я чего-то не понимаю…
            • 0
              интерфейсы лишь заменяют базовый класс Thing как в примере с Multiple dispatch. не более чем абстракция.
              • 0
                В данном случае, они по-моему только мешают, потому что если переписать main вот так:
                ICollidable asteroid = new Asteroid(); ICollidable spaceship = new Spaceship();
                Что просится глядя на определения классов, код становится не рабочим.
                • 0
                  согласен, stackoverflow — не самый приятный момент, однако решением данного случая является именно multiple dispatch.

                  но это при условии, что используется одна иерархия классов. при введении, скажем, AsteroidBase и SpaceshipBase и без интерфейса, тогда все станет на свои места.
            • 0
              А есть конкретный пример системы, где это можно было бы применить?
              • 0
                например, при работе с указателями:

                interface IReader<T>
                {
                    unsafe T Read(void* ptr, int index);
                }
                

                именно здесь не обошлось бы без явной имплементации интерфейса и не надо будет в коде вводить if/else, либо switch для типов аргументов.
                • 0
                  LINQ целиком и полностью основан на дженериках.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.