If it walks like a duck and it quacks like a duck - it is a duck. So duck it and make that work in C# thanks to interceptors!
Note
This is an educational blog post - feel free to us it in production 😉
TypeScript and where we wanna go#
So in TypeScript you can do something like:
class A {
Do(): void { console.log("A.Do"); }
}
class B {
Do(): void { console.log("B.Do"); }
}
function foo(a: { Do(): void }) {
a.Do();
}
And I want to have something similar in 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 and B have nothing in common. No shared base class, no shared interface. ??? is the shape we need to invent - something that lets Foo(new A()) and Foo(new B()) both compile and both call the right Do(), without touching A or B at all.
Also none of the following are allowed:
dynamic- even though that is the coolest ever- accepting
objectand useisorascast stuff - not touching
AorB(like adding a shared base - be it interface or abstract base class)
Interceptors#
Since .NET 8, C# has an experimental compiler feature called interceptors. A source generator can emit a method, slap [InterceptsLocation] on it pointing at a specific call site in your code, and the compiler will silently swap the call to go there instead - at zero runtime cost, because it's resolved entirely at compile time.
The way that normally works is a combination of a custom attribute (DuckType and DuckShape) that is emitted by the source code generator and the interceptor logic - so basically getting all callsides and "do the right thing".
So the usage wise we still something like an interface to "explain the shape" (or property):
[DuckShape]
public interface IDoable { void Do(); }
public static partial class Ops
{
[DuckTyped]
public static void Foo(IDoable a) => a.Do();
}
You write Foo(IDoable a) => a.Do() exactly like you'd want to. [DuckShape] marks IDoable as a structural contract rather than something you're expected to implement. [DuckTyped] tells the generator "this is the real logic, now go make it callable with anything that structurally matches."
I will link the whole generator/interceptor at the end of the article and will mainly show the interesting bids.
Anyway. Each call site resolves to this generic construct:
[InterceptsLocation(1, "…")]
public static void Interceptor_1(A value)
{
Ops.Foo((IDoable)(new ShapeAdapter_IDoable_A(value)));
}
ShapeAdapter_IDoable_A is a tiny generated readonly struct that implements IDoable by forwarding straight to 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();
}
That is the whole trick. It comes with a small dispatch cost (maybe inlining would help here a bit more).
So this perfectly resolves:
Ops.Foo(new A()); // "A.Do"
Ops.Foo(new B()); // "B.Do"
It works for properties too:
[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 = "Steven" }); // "Hello, Steven!"
Conclusion#
So is it useful? Probably not - but that wasn't really the point here. So while the language doesn't allow it directly, one can "emulate" it to a certain extend.
Resources#
- Code for this blog post: https://github.com/linkdotnet/BlogExamples/tree/main/DuckType
- All my blog post examples can be found here: https://github.com/linkdotnet/BlogExamples