Real Traits in C#
Patrick Kelly
Posted on September 19, 2020
What if I told you traits were introduced in C# and even the language designers didn't realize it? Furthermore, that it was introduced with C# 3.0 back in 2007! Yes, I'm actually claiming no one realized this for 13 whole years. You're not likely to believe me, and I don't blame you one bit.
public static Int64 IndexOf<TElement, TCollection>
(this TCollection collection, TElement item)
where TElement : IEquatable<TElement>
where TCollection : IEnumerable<TElement>,
IReadOnlyIndexable<Int64, TElement>
{
Int64 i = 0;
foreach (TElement element in collection) {
if (element.Equals(item)) {
return i;
}
i++;
}
return -1;
}
I'm actually expecting you to beleive this is a universal implementation of IndexOf()
that has nothing to do with the actual collection, and will work with any collection that has the required traits.
Unbelievable, I know.
But it works!
And fascinatingly, there's no pseudo-trait trickery with classes that your type includes like every other "hey this is possible, kind, if you squint your eyes and wave a lot" claim out there. These are actual traits. The DynamicArray<>
type shown in the passing test does not implement IndexOf()
in any capacity. Here's even the full method list as of that test run:
Clearly, we need to break down what's going on, how and why this works, and why I think no one, myself included, realized this had been possible.
What's a trait?
Let's make sure we're all on the same page about what a trait is. This will also be useful in explaining why this approach works.
A trait is a specific feature of a type. A method it supports. A property. Whatever. It's just saying: "I have this thing".
So, we need a way that C# supports for saying types have something. That sounds an awful lot like interfaces. In fact, if we model the interface around that specific feature, instead of going the shadow-class way that most .NET developers go, we have, very close to, traits as they are seen in other languages. We can't add them to existing types like we can in a language with proper traits, but that's the only limitation we have, so that's good.
What's a C# trait?
How does this look like in C# then?
public interface IReadOnlyIndexable<in TIndex, TElement> {
ref readonly TElement this[TIndex index] { get; }
}
The trait is hopefully obvious: "Hey, I'm indexable by a type you specify, and will return a read-only reference to the element at that index".
In a similar vein, IEnumerable<>
is also a trait, and since it's part of the standard library I'm not going to explain it.
Implementing traits
How do we go about implementing this now? Remember, traits, by this pattern, are just interfaces, so you'd implement it like any other interface. DynamicArray<>
looks like this:
public partial class DynamicArray<TElement> :
IAddable<TElement>,
IClearable,
ICloneable<DynamicArray<TElement>>,
ICountable,
IDequeueable<TElement>,
IEnqueueable<TElement>,
IEnumerable<TElement>,
IReverseEnumerable<TElement>,
IEquatable<DynamicArray<TElement>>,
IEquatable<TElement[]>,
IIndexable<Int64, TElement>,
IReadOnlyIndexable<Int64, TElement>,
IInsertable<Int32, TElement>,
IPoppable<TElement>,
IPushable<TElement>,
IRemovable<TElement>,
IReplaceable<TElement>,
IResizable,
IShiftable,
ISliceable<TElement>,
IReadOnlySlicable<TElement>
where TElement : IEquatable<TElement> {
}
Yeah, there's a lot of interfaces when you follow this pattern. Just go through and implement your interfaces like you normally would.
Programming for traits
Now here's where things deviate greatly from what everyone else has been doing. We're going to use generics and extension methods, not type composition, to bring this all together. After all, a major point of traits is supposed to be simplifying your implementations. And what better way of simplifying your implementations than providing single implementations of functions, which then appear on all supported types for free!?
Let's take a look at that IndexOf()
I showed you at the beginning.
public static Int64 IndexOf<TElement, TCollection>
(this TCollection collection, TElement item)
where TElement : IEquatable<TElement>
where TCollection : IEnumerable<TElement>,
IReadOnlyIndexable<Int64, TElement>
{
// Does stuff
}
Here's what's going on with this signature:
where TCollection : IEnumerable<TElement>,
IReadOnlyIndexable<Int64, TElement>
This says that TCollection
has to be an enumerable of TElement
and read-only indexable by Int64
who's elements are TElement
. In both traits, we're saying the collection is of TElement
.
Typically, things are easier to explain when not abstract, so let's degeneralize this whole thing, and explain through a DynamicArray<Char>
. Here's the relevant traits:
public class DynamicArray<Char> :
IEnumerable<Char>,
IReverseEnumerable<Char>,
IIndexable<Int64, Char>,
IReadOnlyIndexable<Int64, Char>,
ISliceable<Char>,
IReadOnlySlicable<Char>
{
}
IEnumerable<Char>
you already know, and IReverseEnumerable<Char>
exists in the library I'm utilizing traits for, but does exactly what you'd expect. You saw the IReadOnlyIndexable<Int64, Char>
interface earlier, and IIndexable<Int64, Char>
works the same way, but returns a ref Char
rather than a readonly ref Char
; in both cases, it allows an index of Int64
to return the Char
at that position. ISlicable<Char>
and IReadOnlySlicable<Char>
are similar to I*Indexable<Char>
, but also include an indexer that takes a Range
type, and three Slice()
operations, with all three returning *Span<Char>
. Because both Range
and Slice()
work with Int32
, I*Slicable<Char>
implies I*Indexable<Int32, Char>
as well. This should actually make sense as Char[]
is actually indexable by Int32
or Int64
.
Out of these, we have two relevant features: DynamicArray<Char>
can have it's Char
enumerated forward or reverse, and can have it's Char
indexed by Int32
or Int64
. Now let's take one last look at IndexOf()
public static Int64 IndexOf<TElement, TCollection>
(this TCollection collection, TElement item)
where TElement : IEquatable<TElement>
where TCollection : IEnumerable<TElement>,
IReadOnlyIndexable<Int64, TElement>
{
Int64 i = 0;
foreach (TElement element in collection) {
if (element.Equals(item)) {
return i;
}
i++;
}
return -1;
}
What was used in the implementation? An enumerator of the elements in the collection. No indexer was used, but because it's counting and incrementing an integer, and this would make no sense for other indexables, like an associative array, we, the human, understand that the type also needs to be indexable by integer. We actually didn't need to know a single thing about the collection itself, only that it had those two traits.
I've been utilizing this approach to greatly simplify a code base that can't be simplified through polymorphism and inheritance alone.
So yes, true, proper, trait programming in C# is possible, with the only limitation that you can't add trait implementations for existing types. Yet.
Why did everyone miss this?
People are afraid of generics. They're complicated and signatures of generic functions can get really verbose. They aren't utilized much outside of very simple templating in most cases. Ask most programmers how they'd combine multiple interfaces, and they'll suggest another interface. That works, but isn't helpful for trait programming.
Posted on September 19, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.