Утиная Типизация в C# с Помощью Перехватчиков
Если это ходит как утка и крякает как утка — значит, это утка. Применим утиную типизацию и заставим это работать в C# с помощью перехватчиков!
Примечание: этот пост написан в образовательных целях, но вы смело можете использовать описанный подход в проде 😉
В TypeScript можно сделать вот так:
class A {
Do(): void { console.log("A.Do"); }
}
class B {
Do(): void { console.log("B.Do"); }
}
function foo(a: { Do(): void }) {
a.Do();
}Попробуем сделать что-то подобное в C#:
public class A
{
public void Do() => Console.WriteLine("A.Do");
}
public class B
{
public void Do() => Console.WriteLine("B.Do");
}
public void Foo(??? a) => a.Do();
У классов A и B нет ничего общего: ни базового класса, ни общего интерфейса. Нужно придумать некую «форму» (обозначим её как
???) — нечто такое, что позволит успешно скомпилировать вызовы Foo(new A()) и Foo(new B()) и обеспечит вызов нужного метода Do() в каждом случае, при этом вообще не затрагивая сами классы A и B.Кроме того, запрещено использовать следующие подходы:
-
dynamic (хотя это и невероятно крутая штука);- аргумент типа
object с последующим приведением типов через is или as;- модификацию классов A или B (например, добавление общего базового класса или интерфейса).
Перехватчики
Начиная с .NET 8, в C# появилась экспериментальная функция компилятора — перехватчики. Генератор исходного кода может создать метод, пометить его атрибутом
[InterceptsLocation], указав конкретное место вызова в вашем коде, и компилятор незаметно перенаправит вызов на этот метод. Это происходит без каких-либо затрат ресурсов во время выполнения, так как всё разрешается на этапе компиляции.Обычно это реализуется через сочетание пользовательских атрибутов (например,
DuckType и DuckShape), создаваемых генератором кода, и логики перехватчика: система находит все места вызовов и выполняет необходимые действия.Таким образом, с точки зрения использования нам всё равно потребуется нечто вроде интерфейса (или свойства) для «описания формы»:
[DuckShape]
public interface IDoable { void Do(); }
public static partial class Ops
{
[DuckTyped]
public static void Foo(IDoable a) => a.Do();
}
Вы пишете
Foo(IDoable a) => a.Do() - именно так, как вам хочется. Атрибут [DuckShape] помечает IDoable как структурный контракт, а не как интерфейс, который нужно реализовывать вручную. Атрибут [DuckTyped] сообщает генератору: «Вот настоящая логика; сделай так, чтобы её можно было вызывать с любым объектом, подходящим по структуре».Полный код генератора/перехватчика на GitHub, а здесь рассмотрим наиболее интересные фрагменты.
Так или иначе, каждый вызов сводится к следующей обобщённой конструкции:
[InterceptsLocation(1, "…")]
public static void Interceptor_1(A value)
{
Ops.Foo((IDoable)(new ShapeAdapter_IDoable_A(value)));
}
ShapeAdapter_IDoable_A - это небольшая генерируемая структура только для чтения, которая реализует IDoable, перенаправляя сразу в A:internal readonly struct
ShapeAdapter_IDoable_A : IDoable
{
private readonly A _value;
public ShapeAdapter_IDoable_A(A value)
=> _value = value;
public void Do() => _value.Do();
}
Вот и весь фокус. Это ведёт к незначительным накладным расходам (возможно инлайнинг поможет с этим). Таким образом, следующий код работает прекрасно:
Ops.Foo(new A()); // "A.Do"
Ops.Foo(new B()); // "B.Do"
Это также работает для свойств:
[DuckShape]
public interface INameable
{ string Name { get; set; } }
public class Person
{ public string Name { get; set; } = ""; }
[DuckTyped]
public static string Greet(INameable n)
=> $"Hello, {n.Name}!";
Greet(new Person { Name = "Jon" }); // "Hello, Jon!"
Это можно оформить в NuGet, если кто-то увидит в этом реальный смысл.
Итого
Полезно ли это? Вероятно, нет — но не в этом главная цель. Хотя язык не поддерживает это напрямую, подобное поведение можно в определённой мере «эмулировать».
Источник: https://steven-giesel.com/blogPost/5170e165-29e8-437a-b5ce-446a84943809/quack-quack-ducktyping-in-c-with-interceptors