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 struct
The 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 struct
can’t be the element type of an array.- A
ref struct
can’t be a declared type of a field of a class or a non-ref struct
.- A
ref struct
can’t implement interfaces.- A
ref struct
can’t be boxed to System.ValueType or System.Object.- A
ref struct
can’t be a type argument.- A
ref struct
variable can’t be captured by a lambda expression or a local function.- A
ref struct
variable can’t be used in an async method. However, you can useref struct
variables in synchronous methods, for example, in methods that return Task or Task. - A
ref struct
variable 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
GetEnumerator
method. Beginning with C# 9.0, theGetEnumerator
method can be a type’s extension method.- The return type of the
GetEnumerator
method has the publicCurrent
property and the public parameterlessMoveNext
method whose return type isbool
.
using
statements
You can also use the
using
statement and declaration with an instance of a ref struct that fits the disposable pattern. That is, it has an instanceDispose
method, which is accessible, parameterless and has avoid
return type.
await
operator
An expression
t
is awaitable if one of the following holds:
t
is of compile-time typedynamic
t
has an accessible instance or extension method calledGetAwaiter
with no parameters and no type parameters, and a return typeA
for which all of the following hold:
A
implements the interfaceSystem.Runtime.CompilerServices.INotifyCompletion
(hereafter known asINotifyCompletion
for brevity)A
has an accessible, readable instance propertyIsCompleted
of typebool
A
has an accessible instance methodGetResult
with 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.