August 16, 2023—A short list of concepts and features in C# over which you cannot abstract (programming)
I love abstractions. One of my favorite things about software is finding, making, and using good abstractions.
C# has many wonderful abstractions.
But C# also has concepts and features over which it’s simply not possible to abstract.
ref structThe ref keyword was introduced to enable performance optimizations. When you make a ref type then you’re telling the compiler that its instances can only live on the stack. They cannot live on the heap.
This has the following consequences:
- A
ref structcan’t be the element type of an array.- A
ref structcan’t be a declared type of a field of a class or a non-ref struct.- A
ref structcan’t implement interfaces.- A
ref structcan’t be boxed to System.ValueType or System.Object.- A
ref structcan’t be a type argument.- A
ref structvariable can’t be captured by a lambda expression or a local function.- A
ref structvariable can’t be used in an async method. However, you can useref structvariables in synchronous methods, for example, in methods that return Task or Task. - A
ref structvariable can’t be used in iterators.
That’s a long list of things you can’t do with them!
Let’s zoom in on the fact that they can’t implement interfaces. Which are one of the primary tools for abstraction in C#.
Suppose you want a function that will return the sum of all the odd elements of a sequence of numbers:
public int SumOdd(IEnumerable<int> sequence)
{
var even = true;
var sum = 0;
foreach (var number in sequence)
{
if (even)
{
even = false;
}
else
{
even = true;
sum += number;
}
}
return sum;
}
Because of the power of abstraction you can use that function on int[], List<int>, ImmutableList<int>, ArraySegment<int>, IReadOnlyCollection<int>, and so on:
Assert.Equal(4, SumOdd(new int[] { 0, 1, 2, 3, 4 }));
Assert.Equal(4, SumOdd(new List<int> { 0, 1, 2, 3, 4 }));
But you cannot use that function on ReadOnlySpan<int>. Instead you have to make a choice:
ReadOnlySpan<int>
public int SumOdd(ReadOnlySpan<int> sequence)
{
var even = true;
var sum = 0;
foreach (ref readonly var number in sequence)
{
if (even)
{
even = false;
}
else
{
even = true;
sum += number;
}
}
return sum;
}
ReadOnlySpan<int> into an array:
ReadOnlySpan<int> span = new int[] { 0, 1, 2, 3, 4 };
Assert.Equal(4, SumOdd(span.ToArray()));
ReadOnlyMemory<int> within scope from which the ReadOnlySpan<int> came. In which case you can make an adapter for ReadOnlyMemory<int>:
public sealed record ReadOnlyMemoryToEnumerableAdapter<T>(ReadOnlyMemory<T> Memory) : IEnumerable<T>
{
public IEnumerator<int> GetEnumerator() => ...;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
…but now we’re no longer talking about abstracting over ReadOnlySpan<int>. Which is kind of the point
So you cannot abstract over ref struct.
Here are some examples of what I mean by “coding by convention”:
foreach loops
You can use it with an instance of any type that satisfies the following conditions:
- A type has the public parameterless
GetEnumeratormethod. Beginning with C# 9.0, theGetEnumeratormethod can be a type’s extension method.- The return type of the
GetEnumeratormethod has the publicCurrentproperty and the public parameterlessMoveNextmethod whose return type isbool.
using statements
You can also use the
usingstatement and declaration with an instance of a ref struct that fits the disposable pattern. That is, it has an instanceDisposemethod, which is accessible, parameterless and has avoidreturn type.
await operator
An expression
tis awaitable if one of the following holds:
tis of compile-time typedynamicthas an accessible instance or extension method calledGetAwaiterwith no parameters and no type parameters, and a return typeAfor which all of the following hold:
Aimplements the interfaceSystem.Runtime.CompilerServices.INotifyCompletion(hereafter known asINotifyCompletionfor brevity)Ahas an accessible, readable instance propertyIsCompletedof typeboolAhas an accessible instance methodGetResultwith no parameters and no type parameters
There are a growing number of things like this in C# where you’ll be able to use this or that feature if you follow a bunch of rules.
But it’s impossible to abstract over all foreach-able types, or all disposable types, or all awaitable types. Because with each of these features C# introduced a convention, and a convention is different than an interface. It doesn’t matter if they also introduced an interface (as is the case with foreach and IEnumerable, or with using and IDisposable)—the fact that they introduced a convention at all means there will be types which are foreach-able that do not implement an interface.
It’s impossible to abstract over all manifestations of any of these concepts.
C# pioneered async/await. And so colored functions are C#’s fault.
Yes, async cancer is a real thing. I’ve experienced it. You are twelve layers deep in a project with 10,000 source code files and discover that you need to call an asynchronous function from a synchronous function. Then you have to waste the rest of the day refactoring the entire application to support async from the top all the way down. And you can only hope that you didn’t break all the completely unrelated things you had to modify just to support this.
You cannot abstract over the color of a function.