Details in C# you don't know

Details in C# you don't know

There is something called duck typing. Duck typing means that if something walks like a duck and quacks like a duck, then it must be a duck.

Last updated 3/29/2022 8:23 AM
hez2010
8 min read
Category
.NET
Tags
.NET C#

Preface

There is a concept called duck typing: if something looks like a duck and quacks like a duck, then it is a duck.

C# also hides many duck-typing-like mechanisms, but many developers are unaware of them and thus cannot leverage them properly. Today, I will enumerate these subtle details hidden in the compiler.

It's Not Only Task and ValueTask That Can Be Awaited

When writing asynchronous code in C#, we often choose to wrap asynchronous logic in a Task or ValueTask so that callers can use await to achieve asynchronous invocation.

However, it's not just Task and ValueTask that can be awaited. Behind the scenes, Task and ValueTask involve the thread pool for scheduling. So why is C#'s async/await described as a coroutine?

The reason is that what you await doesn't have to be a Task/ValueTask. In C#, if your class contains a GetAwaiter() method and a bool IsCompleted property, and the object returned by GetAwaiter() has a GetResult() method, a bool IsCompleted property, and implements INotifyCompletion, then instances of that class are awaitable.

Therefore, when encapsulating I/O operations, we can implement our own awaiter based on low-level epoll/IOCP. This way, when awaited, no threads are created, no thread scheduling occurs—control is simply yielded. When the OS completes the I/O call and notifies user mode via mechanisms like CompletionPort (Windows), the context is resumed and the remaining logic continues. This is essentially a true stackless coroutine.

public class MyTask<T>
{
    public MyAwaiter<T> GetAwaiter()
    {
        return new MyAwaiter<T>();
    }
}

public class MyAwaiter<T> : INotifyCompletion
{
    public bool IsCompleted { get; private set; }
    public T GetResult()
    {
        throw new NotImplementedException();
    }
    public void OnCompleted(Action continuation)
    {
        throw new NotImplementedException();
    }
}

public class Program
{
    static async Task Main(string[] args)
    {
        var obj = new MyTask<int>();
        await obj;
    }
}

In fact, the asynchronous APIs related to I/O in .NET Core are indeed implemented this way. During I/O operations, no threads are assigned to wait for results—it's all coroutine-based: control is yielded after starting the I/O operation and resumed once the I/O completes. If you ever notice that the thread changes before and after an await, it's simply because the Task itself was scheduled.

In UWP development, IAsyncAction/IAsyncOperation<T> are wrappers from low-level layers. They are unrelated to Task but are still awaitable. Moreover, if you develop UWP with C++/WinRT, methods that return these interfaces can also be co_awaited.

It's Not Only IEnumerable and IEnumerator That Can Be Used with foreach

We often write code like this:

foreach (var i in list)
{
    // ...
}

When asked why foreach works, most people answer because list implements IEnumerable or IEnumerator.

But in reality, for an object to be foreachable, it only needs to provide a GetEnumerator() method, and the object returned by GetEnumerator() must have a bool MoveNext() method and a Current property.

class MyEnumerator<T>
{
    public T Current { get; private set; }
    public bool MoveNext()
    {
        throw new NotImplementedException();
    }
}

class MyEnumerable<T>
{
    public MyEnumerator<T> GetEnumerator()
    {
        throw new NotImplementedException();
    }
}

class Program
{
    public static void Main()
    {
        var x = new MyEnumerable<int>();
        foreach (var i in x)
        {
            // ...
        }
    }
}

It's Not Only IAsyncEnumerable and IAsyncEnumerator That Can Be Used with await foreach

Same idea, but with different requirements: GetEnumerator() and MoveNext() become GetAsyncEnumerator() and MoveNextAsync().

The MoveNextAsync() method should return an Awaitable<bool>. This Awaitable can be a Task/ValueTask, or something else you implement yourself.

class MyAsyncEnumerator<T>
{
    public T Current { get; private set; }
    public MyTask<bool> MoveNextAsync()
    {
        throw new NotImplementedException();
    }
}

class MyAsyncEnumerable<T>
{
    public MyAsyncEnumerator<T> GetAsyncEnumerator()
    {
        throw new NotImplementedException();
    }
}

class Program
{
    public static async Task Main()
    {
        var x = new MyAsyncEnumerable<int>();
        await foreach (var i in x)
        {
            // ...
        }
    }
}

How to Implement IDisposable on a ref struct

As we know, ref struct must live on the stack and cannot be boxed, so it cannot implement interfaces. However, if your ref struct has a void Dispose() method, you can use the using syntax to achieve automatic disposal.

ref struct MyDisposable
{
    public void Dispose() => throw new NotImplementedException();
}

class Program
{
    public static void Main()
    {
        using var y = new MyDisposable();
        // ...
    }
}

It's Not Only Range That Enables Slicing

C# 8 introduced Ranges, allowing slice operations. But it's not necessary to provide an indexer that accepts a Range type parameter to use this feature.

As long as your class can be counted (has a Length or Count property) and can be sliced (has a Slice(int, int) method), you can use this feature.

class MyRange
{
    public int Count { get; private set; }
    public object Slice(int x, int y) => throw new NotImplementedException();
}

class Program
{
    public static void Main()
    {
        var x = new MyRange();
        var y = x[1..];
    }
}

It's Not Only Index That Enables Indexing

C# 8 introduced Indexes for indexing, e.g., using ^1 to index the last element. But it's not necessary to provide an indexer that accepts an Index type parameter to use this feature.

As long as your class can be counted (has a Length or Count property) and can be indexed (has an indexer that accepts an int parameter), you can use this feature.

class MyIndex
{
    public int Count { get; private set; }
    public object this[int index]
    {
        get => throw new NotImplementedException();
    }
}

class Program
{
    public static void Main()
    {
        var x = new MyIndex();
        var y = x[^1];
    }
}

Implementing Deconstruction on a Type

How to implement deconstruction on a type? Simply write a method named Deconstruct() with out parameters.

class MyDeconstruct
{
    private int A => 1;
    private int B => 2;
    public void Deconstruct(out int a, out int b)
    {
        a = A;
        b = B;
    }
}

class Program
{
    public static void Main()
    {
        var x = new MyDeconstruct();
        var (o, u) = x;
    }
}

It's Not Only IEnumerable That Can Be Used with LINQ

LINQ is a commonly used integrated query language in C# that allows you to write code like this:

from c in list where c.Id > 5 select c;

However, the type of list in the above code does not necessarily have to implement IEnumerable. In fact, as long as there are extension methods with the corresponding names—like a method named Select for select, a method named Where for where—it works.

class Just<T> : Maybe<T>
{
    private readonly T value;
    public Just(T value) { this.value = value; }

    public override Maybe<U> Select<U>(Func<T, Maybe<U>> f) => f(value);
    public override string ToString() => $"Just {value}";
}

class Nothing<T> : Maybe<T>
{
    public override Maybe<U> Select<U>(Func<T, Maybe<U>> _) => new Nothing<U>();
    public override string ToString() => "Nothing";
}

abstract class Maybe<T>
{
    public abstract Maybe<U> Select<U>(Func<T, Maybe<U>> f);

    public Maybe<V> SelectMany<U, V>(Func<T, Maybe<U>> k, Func<T, U, V> s)
        => Select(x => k(x).Select(y => new Just<V>(s(x, y))));

    public Maybe<U> Where(Func<Maybe<T>, bool> f) => f(this) ? this : new Nothing<T>();
}

class Program
{
    public static void Main()
    {
        var x = new Just<int>(3);
        var y = new Just<int>(7);
        var z = new Nothing<int>();

        var u = from x0 in x from y0 in y select x0 + y0;
        var v = from x0 in x from z0 in z select x0 + z0;
        var just = from c in x where true select c;
        var nothing = from c in x where false select c;
    }
}
Keep Exploring

Related Reading

More Articles