«Duck typing» и C#

    Доброго времени суток. В последнее время я много эксперементрировал с .Net 5 и его Source Generator-ами. И мне внезапно пришла идея как можно использовать Source Generator-ы для реализации "duck typing"-а в C#. Я не мог просто оставить эту идею. В итоге вышла, я бы сказал, чисто академическая штука(никто не будет использовать это на проде, я надеюсь), но результат получился довольно интересен. Всем кому интересно прошу под кат!


    Спойлер

    Я не буду очень углубляться в саму реализацию. Её можно посмотреть в репозитории ссылка на который будет внизу. Там нет ничего сложного для тех кто уже баловался с генераторами, а для всеx остальных потребуется намного большая статья.


    Как этим пользоваться


    Представим что у нас есть следующий пример:


    public interface ICalculator
    {
      float Calculate(float a, float b);
    }
    
    public class AddCalculator
    {
    
      float Calculate(float a, float b);
    }

    Важно отметить что AddCalculator никаким образом не реализует ICalculator.
    Они лишь имеют идентичные сигнатуры. Если мы попытаемся использовать их следующим образом, то у нас ничего не получится:


    var addCalculator = new AddCalculator();
    
    var result = Do(addCalculator, 10, 20);
    
    float Do(ICalculator calculator, float a, float b)
    {
      return calculator.Calculate(a, b);
    }
    

    Компилятор С# скажет следующее:


    Argument type 'AddCalculator' is not assignable to parameter type 'ICalculator'


    И он будет прав. Но поскольку сигнатура AddCalculator полностью совпадает с ICalculator и нам очень хочеться это сделать, то решением может быть duck typing который не работает в С#. Иммено тут и пригодится nuget пакет DuckInterface. Все что нужно будет сделать, это установить его и немножечко подправить наши сигнатуры. Начнем с интерфейса добавив к нему аттрибут Duckable:


    [Duckable]
    public interface ICalculator
    {
      float Calculate(float a, float b);
    }

    Дальше обновим метод Do. Нужно заменить ICalculator на DICalculator. DICalculator это класс который был сгенерен нашим DuckInterface.
    Сигнатура DICalculator полностью совпадает с ICalculator и может содержать неявные преобразования для нужных типов. Все эти неявные преобразования будут генериться в тот момент когда мы пишем код в нашей IDE. Генерится они будуть в зависимости от того как мы используем наш DICalculator.


    Итоговый пример:


    var addCalculator = new AddCalculator();
    
    var result = Do(addCalculator, 10, 20);
    
    float Do(DICalculator calculator, float a, float b)
    {
      return calculator.Calculate(a, b);
    }
    

    И это всё. Ошибок компиляции больше нет и все работает как часы.


    Как это работает


    Здесь используются два независимых генератора. Первый ищет аттрибут Duckable и генерит "базовый" класс для интерфейса. Например, для ICalculator он будет иметь следующий вид:


    public partial class DICalculator : ICalculator 
    {
      [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
      private readonly Func<float, float, float> _Calculate;        
    
      [System.Diagnostics.DebuggerStepThrough]
      public float Calculate(float a, float b)
      {
          return _Calculate(a, b);
      }
    }

    Второй генератор ищет вызовы методов и присваивания чтобы понять как duckable интерфейс используется. Расмотрим следующий пример:


    var result = Do(addCalculator, 10, 20);

    Анализатор увидит что метод Do имеет первый аргумент типа DICalculator, а потом проверит переменную addCalculator. Если её тип имеет все необходимые поля и методы, то генератор расширит DICalculator следующим образом:


    public partial class DICalculator
    {
      private DICalculator(global::AddCalculator value) 
      {
           _Calculate = value.Calculate;
      }
    
      public static implicit operator DICalculator(global::AddCalculator value)
      {
          return new DICalculator(value);
      }
    }

    Поскольку DICalculator это partial class мы можем реализовать подобные расширения для нескольких типов сразу и ничего не сломать. Этот трюк работает не только для методов, но и для пропертей:


    Пример:


    [Duckable]
    public interface ICalculator
    {
        float Zero { get; }
        float Value { get; set; }
        float Calculate(float a, float b);
    }
    // ....
    public partial class DICalculator : ICalculator 
    {
        [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
        private readonly Func<float> _ZeroGetter;
    
        [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
        private readonly Func<float> _ValueGetter;
    
        [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
        private readonly Action<float> _ValueSetter;
    
        [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
        private readonly Func<float, float, float> _Calculate;        
    
        public float Zero
        {
             [System.Diagnostics.DebuggerStepThrough] get { return _ZeroGetter(); }
        }
    
        public float Value
        {
             [System.Diagnostics.DebuggerStepThrough] get { return _ValueGetter(); }
             [System.Diagnostics.DebuggerStepThrough] set { _ValueSetter(value); }
        }
    
        [System.Diagnostics.DebuggerStepThrough]
        public float Calculate(float a, float b)
        {
            return _Calculate(a, b);
        }
    }

    Что не работает


    На этом хорошие новости закончились. Всё-таки реализовать прямо вездесущий duck typing не получится. Поскольку мы скованы самим компилятором. А именно будут проблемы с дженериками и ref struct-урами. В теории часть проблем с дженериками можно починить, но не все. Например, было бы прикольно чтобы мы могли использовать наши интерфейсы вместе с where как-то вот так:


    float Do<TCalcualtor>(TCalcualtor calculator, float a, float b)
        where TCalcualtor: DICalculator
    {
      return calculator.Calculate(a, b);
    }

    В таком случае мы могли бы получили прямо zero cost duct typing(и щепотку метапрограмирования, если копнуть глубже), поскольку, мы легко можем заменить partial class на partial struct в реализации нашего duck интерфейса. В результате, было бы сгенерено множестао Do методов для каждого уникального TCalcualtor как это происходит со структурами. Но увы, компилятор нам скажет, что ничего такого он не умеет.
    На этом все. Спасибо за внимание!


    Nuget тут
    Github тут

    Комментарии 5

      +3
      Тут больше вопрос в том не «Как это работает» и «Что не работает», а «Зачем это работает». Или just for fun?
        +4
        Хотелось посмотреть что полезного получится из этой идеи. В итоге получилось just for fun.
        0

        Можно ещё сказать что для классов к которым хочется применить подобный подход, указывайте их как partial, тогда при source generation просто создаём дополнительный код


        public partial class AddCalculator: ICalculator { }


        Т.е. явно, но автоматически назначаем интерфейс классам которые его реализуют, но не объявляют.


        Особого смысла не видно. Основное преимущество у duck typing это возможность использовать интересную типизацию, а подобный подход в этом не помошник.

          0
          Если AddCalculator установлен вместе с каким-нибуть нюгетом это не сработает.
            0

            Ну ваш вариант тоже не сработает если методы работы с ICalculator придут из nuget. Потому что требуется чтобы параметр метода принимал DICalculator.


            Ну а так, конечно интересное применение неявной конвертации.

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

        Самое читаемое