Эта статья объясняет, что такое ad-hoc-полиморфизм, какие проблемы он решает и как вообще его реализовать, используя паттерн type class на языке программирования C#.

▍ Виды полиморфизмов


Оказывается, что полиморфизмов есть, как минимум, три вида:

  1. Параметрический.
  2. Специальный (ad-hoc).
  3. Полиморфизм подтипов.

Начнём с параметрического полиморфизма. Допустим, у нас есть список элементов. Это может быть список целых чисел, чисел с плавающей запятой, строк, чего угодно. Теперь представьте метод GetHead(), который возвращает первый элемент из этого списка. Для него не важно, является ли возвращаемый элемент типом int, string, Apple или Orange. Его возвращаемый тип — это формальный типовой параметр, стоящий вместо T внутри IList<T>, и его реализация одинакова для всех типов: «вернуть первый элемент».

interface IList<T>
{
    T GetHead();
}

В отличие от параметрического полиморфизма, специальный полиморфизм привязан к типу. В зависимости от него вызываются разные реализации метода. Перегрузка методов — один из примеров ad-hoc-полиморфизма. Например, можно иметь две версии метода, присоединяющего первый элемент ко второму — одну, которая принимает два целых числа и складывает их, и другую, которая принимает две строки и конкатенирует их. Вы знаете, что 2 + 3 = 5, но "2" + "3" = "23".

class Appender
{
    public int AppendItems(int a, int b) =>
        a + b;

    public string AppendItems(string a, string b) =>
        $"{a}{b}";
}

При полиморфизме подтипов дочерние классы предоставляют разные реализации метода некоторого базового класса. В отличие от специального полиморфизма, где решение о том, какая реализация вызывается, принимается на этапе компиляции (раннее связывание), в полиморфизме подтипов оно принимается во время выполнения (позднее связывание).

abstract class Animal
{
    public abstract int GetMeatMass();
}

class Cow : Animal
{
    public override int GetMeatMass() => 20;
}

class Dog : Animal
{
    public override int GetMeatMass() => 5;
}

Теперь давайте ближе рассмотрим ad-hoc-полиморфизм, два других рассматривать подробно в этот раз не будем. Как уже было ранее сказано, перегрузка методов является одним из способов достижения специального полиморфизма: каждая «версия» метода будет принимать разные параметры, и при вызове метода будет выбрана правильная реализация на основе типа предоставленных параметров. Но представим, что есть другой сценарий — предположим, мы хотим иметь только один метод (можем назвать его AppendItems()), и хочется, чтобы этот метод принимал два «присоединяемых» элемента. Если вызывается с целыми числами, они должны быть присоединены с использованием арифметического сложения. Если вызывается со строками, они должны быть присоединены с использованием конкатенации. Можно придумать реализации для различных других типов, но для нашего примера int и string будет достаточно.

Конечно, есть вариант решить задачу «в лоб» — писать перегрузку на каждый тип данных, однако хотелось бы заставить компилятор помогать нам, не создавая 100500 методов.

class Appender
{
    public int AppendItems(int a, int b) =>
        a + b;

    public string AppendItems(string a, string b) =>
        $"{a}{b}";

    public bool AppendItems(bool a, bool b) =>
        a || b;
}

▍ Как соединять уток


Итак, необходимо, чтобы метод AppendItems() принимал два экземпляра чего-то «присоединяемого» и выполнял над ними операцию соединения. Также требуется, чтобы эта операция имела разные реализации для различных «присоединяемых» объектов. Для целых чисел — сложение, для строк — конкатенация. Это наилучший пример специального полиморфизма.

Обратите внимание, что метод должен иметь всего одну реализацию — без перегрузки или переопределения! Как же он может выполнять разные операции для разных типов? Итак, идея заключается в том, что метод AppendItems() даже не должен знать, как реализована операция присоединения; он должен просто её вызвать. Вот сам метод:

class Appender
{
    T AppendItems<T>(T a, T b) => a.Append(b);
}

В этом самая сложная часть — нужно каким-то образом получить операцию присоединения для целых чисел и строк. Как? Тем не менее, в глазах нашего метода AppendItems() они не будут целыми числами и строками. Они будут чем-то «присоединяемым». Это, по сути, пример утиной типизации по поведению: если оно ходит как утка и крякает как утка, то по нашему мнению — это утка. Нам не важно, что это на самом деле, всё, что нас волнует, это то, что оно способно крякать. Вот что и сделаем здесь, только вместо того чтобы значения умели крякать, необходимо, чтобы они могли присоединяться.

Конечно, метод выше не cкомпилируется, так как у обобщённого типа T нет метода Append(). И что же здесь делать? Я объясню два подхода к решению этой проблемы — контейнерные типы и паттерн type class.

▍ Контейнерные типы


Дабы убедить компилятор в том, что тип T по-настоящему присоединяемый, можно использовать подход с контейнерными типами, который задействует достаточно распространённые механизмы в C#. Тогда получится реализовать метод AppendItems() похожим образом на то, как было показано выше.

class Appender
{
    public T AppendItems<T>(AppendableValue<T> a, AppendableValue<T> b) =>
        a.Append(b);
}

Этот метод говорит: «Я беру два элемента типа AppendableValue<T> и присоединяю их». Компилятор отвечает: «Хорошо, я позволю вам вызвать метод Append() на значении типа AppendableValue<T>, потому что вы обещали, что у него будет метод Append(), и я полагаюсь на это». Если его нет на этапе компиляции, компилятор будет мягко говоря недоволен, потому что нарушено обещание, и компиляция завершится с ошибкой.

Ладно, метод AppendItems() получен. Перейдём к типу AppendableValue<T>.

Во-первых, AppendableValue<T> будет абстрактным классом. Прежде всего, достаточно удобно использовать абстрактный класс для реализации контейнерного типа, в случае если нужно передать некоторые параметры в конструктор базового класса, некоторый код на C# унаследует его и так далее.

Во-вторых, AppendableValue<T> будет параметризован — у него будет формальный типовой параметр. Почему? Ну, потому что его операция Append() (не путать с нашим методом AppendItems()!) является обобщённой. Она будет реализована по-разному для разных типов, в нашем случае как сложение для целых чисел и как конкатенация для строк. Поскольку метод Append() зависит от типа, весь абстрактный класс зависит от типа. Обратите внимание, что если бы он не зависел от типа, то это был бы случай параметрического полиморфизма, но так как зависит, это случай специального полиморфизма.

Итак, вот наш милый маленький контейнерный тип, реализованный при помощи абстрактного класса:

abstract class AppendableValue<T>
{
    public T Value { get; }

    protected AppendableValue(T value) =>
        Value = value;

    public abstract T Append(AppendableValue<T> item);
}

Теперь, когда создано определение контейнерного типа, давайте напишем две разные реализации для него: одну для int и другую для string. Вот они:

class AppendableIntValue :
    AppendableValue<int>
{
    public AppendableIntValue(int value) :
        base(value) { }

    public override int Append(AppendableValue<int> item) =>
        Value + item.Value;
}

class AppendableStringValue :
    AppendableValue<string>
{
    public AppendableStringValue(string value) :
        base(value) { }

    public override string Append(AppendableValue<string> item) =>
        $"{Value}{item.Value}";
}

Интерполяция строк здесь для демонстрационных целей — я мог бы просто написать (и более обычно) Value + item.Value, что также хорошо бы объединило их, но я специально хотел, чтобы AppendableStringValue отличался от AppendableIntValue, дабы подчеркнуть, что реализация специфична для каждого типа.

В целом, это было несложно, не так ли? Теперь мы можем передавать обычные экземпляры контейнерных типов в AppendItems(), и всё будет проходить проверку типов. Вот собственно код с использованием всех наработок:

var appender = new Appender();
Console.WriteLine(
    appender.AppendItems(
        new AppendableIntValue(1),
        new AppendableIntValue(2)));
Console.WriteLine(
    appender.AppendItems(
        new AppendableStringValue("1"),
        new AppendableStringValue("2")));

▍ Паттерн type class


С контейнерными классами было весело. Однако с паттерном type class дело обстоит ещё веселее — он более гибкий и, следовательно, более мощный. Но вместо того чтобы принимать мои слова на веру, читайте дальше и убедитесь в этом сами.

Type class — это концепция, возникшая в Haskell. Самый простой способ описать её с точки зрения того, что мы уже знаем на данный момент, заключается в том, что вместо упаковки значений в контейнерные типы AppendableIntValue и AppendableStringValue для выполнения операции над ними, сами типы бы предлагали возможность выполнить операцию над ними. По сути, это есть отделение данных от операции.

Это означает, что нам нужно немного изменить наш метод AppendItems(). Он всё равно принимает два элемента для присоединения, но вместо контейнерного типа теперь требуется класс присоединяемого типа. Наиболее близким описанием в C# был бы следующий код, хоть и невозможный:

class Appender
{
    public T AppendItems<IAppendable<T>>(T a, T b) =>
        IAppendable.Append(a, b);
}

В отличие от Haskell, классы типа не представляют собой существующую структуру в C# и их необходимо моделировать (например, подобно монадам). Обычно это делается через определение интерфейса класса типа, который реализуют для различных конкретных типовых параметров. Позвольте мне предоставить вам немного строк кода, которые демонстрируют эту концепцию во всей её красе:

interface IAppendable<T>
{
    T Append(T a, T b);
}

struct AppendableInt : IAppendable<int>
{
    public int Append(int a, int b) =>
        a + b;
}

struct AppendableString : IAppendable<string>
{
    public string Append(string a, string b) =>
        $"{a}{b}";
}

Видите? Eсть интерфейс IAppendable<T> и две его реализации: AppendableInt и AppendableString.

Одна из крутых вещей о type class заключается в том, что легко расширять библиотеки, не имея доступа к их исходному коду. Например, при желании поддерживать какие-то типы, отличные от int и string, нужно лишь предоставить новые реализации для этих типов. Помимо этого, можно не только предоставить реализации для новых типов, но и переопределить реализации для существующих типов, например, чтобы присоединять целые числа с использованием умножения вместо сложения.

struct AppendableIntMultiplicative : IAppendable<int>
{
    public int Append(int a, int b) =>
        a * b;
}

Дальше встаёт вопрос: каким образом нужно доработать метод AppendItems(), чтобы использовать интерфейс IAppendable<T>? Как вы могли заметить, все реализации интерфейса созданы в виде структур, используя ключевое слово struct. Это позволяет в обобщённой среде создавать экземпляры таких объектов, используя оператор default и zero cost allocation. То есть создание экземпляра присоединяемого type class ничего не стоит! Правда, за это нужно использовать два формальных типовых параметра.

class Appender
{
    public T AppendItems<TAppendable, T>(T a, T b)
        where TAppendable : struct, IAppendable<T> =>
        default(TAppendable).Append(a, b);
}

А вот так это используется.

Console.WriteLine(appender.AppendItems<AppendableInt, int>(1, 2));
Console.WriteLine(appender.AppendItems<AppendableString, string>("1", "2"));

▍ Вывод


Ad-hoc-полиморфизм выражается в языке программирования C# путём перегрузки методов. В зависимости от подставляемого типа, выбирается нужная реализация метода, то есть, компилятор помогает нам в рамках раннего связывания. Чтобы заставить его продолжать помогать нам, имея один универсальный контракт вместо множества перегрузок, существует мощный паттерн type class из мира функционального программирования, который реализуется в C# путём отделения операции от данных.

▍ P.S.


Кстати, помимо этой статьи есть proposal в репозитории dotnet и библиотека language-ext, где можно найти больше интересных примеров:


Ещё я веду Telegram-канал StepOne, куда выкладываю много интересного контента про коммерческую разработку, C# и мир IT глазами эксперта.

Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх ????️

Комментарии (30)


  1. Kelbon
    29.08.2023 14:32
    -5

    И вы до сих пор думаете, что С# и Java не были ошибками? Настолько сложные навороты вокруг абсолютно элементарной вещи, которая должна писаться в одну строку. А тут ещё и оверхед на рантайме огромный


    1. kolebynov
      29.08.2023 14:32
      +3

      Навороты конечно непростые, но про оверхед вы не правы. Эти "аппендеры" являются структурами нулевого размера, которые используются как типы-параметры в дженерик методе, тут нет никаких аллокаций и кастов и, скорее всего, весь код "аппендеров" будет заинлайнен JIT'ом.


      1. Kelbon
        29.08.2023 14:32
        -1

        JIT это значит нужно на рантайме что то инлайнить куда то


        1. s207883
          29.08.2023 14:32
          +1

          Может, сможете предложить хорошую замену? :D


          1. Kelbon
            29.08.2023 14:32
            -2

            C++, то что пишется в статье выглядит там просто как

            a + b

            И никакого джита и рантайм оверхеда


            1. s207883
              29.08.2023 14:32
              +1

              То есть, вместо "jit оверхеда" предлагается язык, созданный для отстрела ног? Спс, но я лучше перейду с C# на Python, чем на это недоразумение из 80-х, даже создатель которого рекомендует писать на чем-то другом. Или останусь в мире оверхеда, зато с GC и отсутствием 10 способов выделить и освободить память, половина из которых объявлена ересью.


              1. Kelbon
                29.08.2023 14:32
                -3

                ну тут я даже ничего говорить не буду, бессмысленно, пишите с гц, джитом, абстрактными фабриками абстрактных фабрик, мне до этого дела особо нет, главное чтобы я не этим занимался


              1. iboltaev
                29.08.2023 14:32
                +2

                я хоть на C++ и не пишу уже лет 8, но меня удивляет, как джависты/шарписты, которые имеют представление о C++ на уровне универских лаб, цепляются к этой памяти и своему GC, как будто других ресурсов, типа сокетов, файловых дескрипторов итд, у них нет. Лет 100 уже в плюсах никто памятью вручную не рулит, есть RAII. Даже в Scala typeclass-ы используются, а в плюсах есть такая вещь как концепты. Плюсы вообще самый гибкий язык со статической типизацией из тех, с которыми я работал, достаточно почитать Александреску "Как я поел грибов современное проектирование на C++"


                1. Kelbon
                  29.08.2023 14:32

                  Вчера настроения не было писать, но да, однажды я программисту на Java рассказал как на самом деле устроено "управление памятью" в С++(просто как работают деструкторы и область видимости) и он не мог поверить, что вот так вот просто можно выкинуть гц, а ещё менеджить огромное количество других ресурсов с помощью этого механизма

                  А потом эти же программисты учат все реализации гц, чтобы знать это и учитывать, чтобы код выполнялся за конечное время и пишут int a,b вместо int a; int b, потому что так лучше оптимизирует jit.

                  Вообще это громадный миф, что у jit есть какая то там информация которая ему что-то позволяет лучше оптимизировать, фактически java/C# и тд неоптимизируемые языки силу того как они работают, если посмотреть на то что компилятор делает с С++ кодом конечно


                  1. hVostt
                    29.08.2023 14:32
                    +1

                    Можно долбиться в байты, а можно делать дела и зарабатывать. За много лет .NET ни разу не подвёл, никаких проблем с GC не видывал. По удобству разработки и инструментария топ.

                    Извините "дрочить" на байты и процессорные тики, это отдельный узкий профиль. Как говорится, если действительно понадобится Си, или С++, да хоть брейнфак -- возьмём его.

                    Ох уж этот религиозный фанатизм GC/не-GC, ой да там RAII, ой а у .NET-а рефлексия и аспекты, и т.д. и т.п.

                    Я точно знаю одно. Разработка большого проекта аля учётная система, на С++ будет дороже на порядок, ещё и дольше. А будет ли ощутимый выигрыш? Я очень сомневаюсь, так как .NET ещё не был узким местом в моей практике нигде.


                    1. Kelbon
                      29.08.2023 14:32

                      Это очередной миф, что в С++ обязательно надо долбиться в байты. Не надо.

                      На С++ сложнее научиться писать, но после того как умеешь - разработка ни разу не медленнее чем на любом другом языке, а может и быстрее, язык позволяет добиться любого уровня абстракции, в этом плане он гибче и си шарпа и джавы и питона и тд

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


                  1. ryanl
                    29.08.2023 14:32
                    +1

                    Судя по фразам, легко делается вывод, что вы засели в своем cpp-болотце. Ой нехорошо, надо гибче быть и объективно сравнивать. А так как компетенций нет (в контексте огромных .NET-овских пластов знаний), то лучше избегать фанатизма.
                    У плюсовиков это пассивный навык - не считать других разработчиков за людей, так как они выстрадали свои годы опыта.)


          1. 0xd34df00d
            29.08.2023 14:32

            Делать все в компил-тайме, как в нормальных языках ML-семейства.


    1. DBalashov
      29.08.2023 14:32

      в современных реализациях вышеупомянутых платформ JIT развернёт это как надо и вполне может быть что заинлайнит вызов метода.


  1. Kerman
    29.08.2023 14:32
    +8

    Какая-то неловкая статья о том, как присобачить дженерики туда, куда их не надо присобачивать. Очень наркомански выглядит метод класса, который принимает два параметра типа Т, чтобы склеить их в тот же тип Т.

    Ладно, если использовать экстеншены, то да, на вход приходит два аргумента, но первый помечен ключевым словом this, а значит его вызов будет с одним аргументом.

    В реальном коде склейки, плюсы, аппенды и прочие конкатинации работают в типе класса и принимают ОДИН аргумент типа класса и выдают свой же тип как результат. Зачем тут дженерик? Чтобы клеить все и вся не учитывая тип? Таких задач в жизни не бывает.

    Ближе всего я вижу необходимость помечать классы как способные агреггироваться. Но в данном случае решение унаследоваться от абстрактного класса - это говёное решение. В реальной жизни классу уже есть от чего наследоваться, а для обозначения возможностей классов придумали интерфейсы. В таком случае не надо вот этих всех "мудростей", просто наследуем IPohooy<T> и реализуем метод интерфейса T Pohooy(T second). Всё

    ¯\_(ツ)_/¯


  1. VBDUnit
    29.08.2023 14:32
    +1

    А почему не подходит такой вариант?

    class Appender
    {
        public T AppendItems<T>(T a, T b) where T : IAppendable<T> => a.Append(b);
        public int AppendItems(int a, int b) => a + b;
        public string AppendItems(string a, string b) => a + b;
    }


    1. Dotarev
      29.08.2023 14:32
      +1

      Потому что если a==null неизбежно словишь exception. А в default(TAppendable).Append(a, b); можно обработать эту ситуацию.


      1. VBDUnit
        29.08.2023 14:32
        +2

        class Appender
        {
            public T AppendItems<T>(T a, T b) where T : IAppendable<T> => a is not null ? a.Append(b) : b;
            public int AppendItems(int a, int b) => a + b;
            public string AppendItems(string a, string b) => $"{a}{b}";
        }

        Так?


        1. Dotarev
          29.08.2023 14:32
          +1

          Да, примерно так


  1. IL_Agent
    29.08.2023 14:32

    Вместо того, чтобы создавать объект нужной реализации IAppendable через default, можно же просто передать функцию append для нужных типов.


    1. ryanl
      29.08.2023 14:32
      +1

      Каких именно типов? - Их может быть очень много.
      Задача - иметь один интерфейс для конкатенации произвольных типов. А не городить по новой перегрузке на каждый тип.


      1. IL_Agent
        29.08.2023 14:32

        Имею в виду вот такое

        class Appender
        {
            public T AppendItems<T>(T a, T b, Func<T, T, T> append) => append(a,b)
        }

        Т.е. вместо структуры с методом писать функцию и её передавать.


        1. ryanl
          29.08.2023 14:32
          +1

          Ну если клиентский код может предоставить метод, который выполняет конкатенацию (Func<T,T,T>), то зачем нужен Appender класс? Это уже бесполезная игра с кодом.
          API должен выглядеть просто, несмотря на то, что там будет 100500 потомков, реализующих Append.


          1. IL_Agent
            29.08.2023 14:32
            -1

            Если клиентский код может предоставить структуру с методом, то может предоставить и метод)

            Вообще в ООП мире это называется паттерн стратегия или policy, тут ничего нового.


  1. itmind
    29.08.2023 14:32
    +1

    Конечно, есть вариант решить задачу «в лоб» — писать перегрузку на каждый тип данных, однако хотелось бы заставить компилятор помогать нам, не создавая 100500 методов.

    Т.е. вместо 100500 методов вы предлагаете создать 100500 реализаций абстрактного класса? В чем профит?


  1. rusdec
    29.08.2023 14:32

    Спасибо за статью.

    > Конечно, есть вариант решить задачу «в лоб» — писать перегрузку на
    каждый тип данных, однако хотелось бы заставить компилятор помогать нам,
    не создавая 100500 методов.

    Но в разве нам тогда не нужно будет создавать 100500 классв AppendableTYPE?
    Пока не могу понять, когда какой полиморфизм лучше использовать.


    1. s207883
      29.08.2023 14:32

      del


  1. Malstream
    29.08.2023 14:32
    +2

    Для таких задач в современном дотнете есть Generic Math, например операция сложения


    1. Stefanio Автор
      29.08.2023 14:32

      Да, прекрасно знаю об этом, однако прикрепил в конец статьи proposals, которые некоторые рассматривали как альтернативу для решения задачи обобщения алгоритмов, в том числе в обсуждении Generic Math в GitHub.

      К сожалению, при ФП подходе придётся крафтить что-то вроде Scala implicits, а этого никто в здравом уме конечно делать не будет.
      Поэтому, для C# был избран другой путь, как мне кажется более подходящий.


  1. Naf2000
    29.08.2023 14:32
    +3

    Так как позднего связывания здесь не может быть, то можно всё переписать на статику:

    Console.WriteLine(Appender.AppendItems<AppendableInt, int>(1, 2));
    Console.WriteLine(Appender.AppendItems<AppendableString, string>("1", "2"));
    
    interface IAppendable<T>
    {
        abstract static T Append(T a, T b);
    }
    
    struct AppendableInt : IAppendable<int>
    {
        public static int Append(int a, int b) =>
            a + b;
    }
    
    struct AppendableString : IAppendable<string>
    {
        public static string Append(string a, string b) =>
            $"{a}{b}";
    }
    
    static class Appender
    {
        public static T AppendItems<TAppendable, T>(T a, T b)
            where TAppendable : IAppendable<T> =>
            TAppendable.Append(a, b);
    }