«Duck typing» и C#

?v=1

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


Спойлер

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


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

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 _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 _ZeroGetter;

    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
    private readonly Func _ValueGetter;

    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
    private readonly Action _ValueSetter;

    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] 
    private readonly Func _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 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 тут: https://www.nuget.org/packages/DuckInterface/
Github тут: https://github.com/byme8/DuckInterface

© Habrahabr.ru