DEV Community

Patrick Kelly
Patrick Kelly

Posted on

Real Traits in C#

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;
}
Enter fullscreen mode Exit fullscreen mode

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!

Proof screenshot

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:

Method list

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; }
}
Enter fullscreen mode Exit fullscreen mode

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> {
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Here's what's going on with this signature:

where TCollection : IEnumerable<TElement>,
        IReadOnlyIndexable<Int64, TElement>
Enter fullscreen mode Exit fullscreen mode

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>
{
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (2)

Collapse
zacharypatten profile image
Zachary Patten

This style of interfacing is likely going to be obsoleted if/when Shapes are added to C#, especially if Shape extension methods are added to C#. github.com/dotnet/csharplang/issue...

Collapse
entomy profile image
Patrick Kelly Author

Yeah, and I hope that's the case. Dedicated syntax is preferrable to patterns, especially as convoluted as the generic signatures can get.