VF://
Vladyslav Furdak
C#Feb 21, 202615 min read

You know C#, but not well enough

Vladyslav Furdak
Vladyslav Furdak
Senior Software Engineer & Microsoft MVP
You know C#, but not well enough

Hi, my name is Vlad, and in this article, I’ll prove that you don’t know C# syntax well enough :)

Let’s walk through a few language nuances - not necessarily used every day, but very real and important in practice.

"A record is like a struct, just better."

This is, of course, incorrect. Why does this reveals weak understanding?

People often confuse the equality model (value-based equality) with the runtime type category (reference vs. value). By default, a record is a reference type - in other words, a class. It lives on the heap, has object identity, is managed by the GC, and so on.

public record Person(string Name);
var a = new Person("Mykola");
var b = new Person("Mykola");
Console.WriteLine(a == b);   // True  (value-based equality)
Console.WriteLine(ReferenceEquals(a, b)); // False (different objects)

How it works:

  • The compiler generates Equals, GetHashCode, and the ==/!= operators so that equality is based on member values.
  • A positional record declaration ((string Name)) generates init properties and a Deconstruct method.
So "under the hood" this is still a regular class.

But there is also a record struct

public readonly record struct Point(int X, int Y);
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1.Equals(p2)); // True

A record struct is a value type, but with record-like behavior. It typically provides:

  • value-based equality
  • "with" operator support
  • Deconstruct support

A record is not fully immutable

Yes - but also no.

A record is still a class. It simply gives you convenient syntax to declare a primary constructor without a body, and the compiler generates init properties and a deconstructor for you. But the type is still a class, and nothing prevents you from adding mutable state.

This is perfectly valid C#:

public record User(string Login)
{
   public int Age { get; set; } // mutable!
}

var u = new User("neo") { Age = 30 };
u.Age = 31; // totally valid

What you cannot do is mutate an init property after initialization. In that case, you need to create a new instance using "with".

“with” can update both positional members and properties declared in the record body:

var u2 = u with { Login = "trinity", Age = 25 };

Can classes have the modifiers private, protected, protected internal, or private protected?

They can - but only when they are nested inside another class.

Top-level classes can only be public or internal. Other access modifiers are not allowed at the top level because they only make sense when a type is a member of another type.

Nested types, on the other hand, can use any access modifier because they become members of the containing class:

public class Outer
{
   private class Secret { }
   protected class ProtectedInner { }
   internal class InternalInner { }
   private protected class PPInner { }
}

"in/out in delegates doesn’t change anything"

Variance (co/contra-variance) is supported by default for delegate type parameters in terms of signature compatibility - the ability to use a derived or base type in place of the original type in a compatible position. However, it is not automatically applied to assigning delegate instances unless the generic delegate itself is declared variant.

So, let’s break down what in and out have to do with it.

Assume the following class hierarchy:

public class Animal { public string Name { get; set; } }
public class Cat : Animal { }
public class Dog : Animal { }

Now define four delegates:

Two delegates represent a factory method signature that returns Animal:

public delegate TAnimal OutAnimalFactory<out TAnimal>();
public delegate TAnimal AnimalFactory<TAnimal>();

Two delegates represent a method that only accepts Animal as an argument:

public delegate void InAnimalProcessor<in TAnimal>(TAnimal arg);
public delegate void AnimalProcessor<TAnimal>(TAnimal arg);

Key rules:

  • The out keyword can be used only for output positions (return types).
  • The in keyword can be used only for input positions (method parameters).

So what do in and out actually give you in a delegate signature?

  • out - output position: the result can be more specific. In other words, instead of returning just Animal, the method can return Cat or Dog.
  • in - input position: the parameter can be more general. In other words, a method that accepts Animal can be used where Cat or Dog is expected.
OutAnimalFactory<Cat>  makeCat  = () => new Cat();
OutAnimalFactory<Animal> okFactory  = makeCat;  // ✅ compiling

InAnimalProcessor<Animal> processAny = a => Console.WriteLine($"Got a {a.GetType().Name}");
InAnimalProcessor<Cat> okProcessor = processAny; // ✅ compiling

AnimalFactory<Cat> invMakeCat = () => new Cat();
AnimalFactory<Animal> badFactory = invMakeCat; // ❌will not work without out

AnimalProcessor<Animal> invProcessor = a => Console.WriteLine($"Got a {a.GetType().Name}");
AnimalProcessor<Cat> badProcessor = invProcessor; // ❌ will not work without in

"Custom delegates are no longer needed - we have Func/Action"

In many cases, Func<> and Action<> are indeed enough (and they are already variant: Func<out TResult>, Action<in T>). But there are scenarios where a custom delegate is either required or significantly better.

You typically need a custom delegate when you want:

  • ref/in/out parameters (Func/Action do not support them)
  • attributes on the delegate itself or on its parameters
  • a readable, explicit name in a public API
  • a signature that precisely matches an event contract or a specific pattern

Example using out and "Try" semantics:

public delegate bool TryParseRef(ReadOnlySpan<char> s, out int value);

Or custom delegates in UI programming:

public delegate void ClosingHandler(object sender, ref bool cancel);
public delegate void KeyPressedHandler(object sender, int keyCode, ref bool handled);

A more accurate way to phrase it:

Func<> and Action<> are the default choice. Custom delegates still matter in desktop development and in very targeted API design scenarios where signature, semantics, or tooling support are important.

Interfaces can have virtual and abstract members

Modern C# has supported this for a while.

Default Interface Methods (C# 8+)

Interfaces can declare methods with bodies. Semantically, these behave like "virtual" members: an implementing type can inherit the default implementation (unless it provides its own).

public interface ILogger
{
   void Log(string msg); // abstract (no body)
   void Info(string msg) => Log("[INFO] " + msg); // has a body
}

A class must implement Log, while Info can be inherited as-is.

static abstract/virtual members in interfaces (C# 11+)

C# 11 introduced static abstract members in interfaces - the foundation for "generic math" and compile-time constraints over operators and static APIs.

public interface IAddable<T>
{
   static abstract T operator +(T a, T b);
}

public static T Sum<T>(T a, T b) where T : IAddable<T> => a + b;

So, in modern C#, an interface is not always a "pure abstraction". It can also serve as a mechanism for backward-compatibility with code (via default implementations) and for expressing powerful generic constraints (via static abstract members).

IQueryable can be in-memory

Why would you use IQueryable against an in-memory collection in real scenarios?

I have the following example: one filtering pipeline for EF and in-memory (expression-based specifications)

You store filters as Expression<Func<T, bool>> so that:

  • In EF, the expression can be translated to SQL
  • In tests or offline mode, the same expression can be applied to a list

Example:

Expression<Func<User, bool>> IsAdult() => u => u.Age >= 18;
IQueryable<User> Apply(IQueryable<User> src)
   => src.Where(IsAdult());

//Now you can use the same Apply(...) method in both environments:
// Production: EF
var dbQuery = Apply(dbContext.Users);
// Tests / offline: in-memory
var memQuery = Apply(usersList.AsQueryable());

In this setup, in-memory IQueryable is primarily an API unification tool - the same query composition method works both for EF-backed queries and for in-memory collections.

Count() does not always enumerate the entire collection

Did you assume that Count() in LINQ always performs a full iteration over the collection?

In reality, it doesn’t - not in every case. Let’s take a look at the source code of Count() to see why.

the source code of Count() method

As you can see, Count() tries to avoid enumerating the source until it has no other option.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

public static int Count<TSource>(this IEnumerable<TSource> source)
        {
            if (source == null)
            {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
            }
            if (source is ICollection<TSource> collectionoft) //<< here
            {
                return collectionoft.Count;
            }
            if (source is IIListProvider<TSource> listProv)  //<< here
            {
                return listProv.GetCount(onlyIfCheap: false);
            }
            if (source is ICollection collection)  //<< here
            {
                return collection.Count;
            }
            int count = 0;
            using (IEnumerator<TSource> e = source.GetEnumerator())
            {
                checked
                {
                    while (e.MoveNext())
                    {
                        count++;
                    }
                }
            }
            return count;
        }

LINQ Zip / Chunk / ToLookup / Repeat

Do you use these LINQ methods?

Zip - pairwise merge

var names = new[] { "A", "B", "C" };
var ages  = new[] { 10, 20 };
var zipped = names.Zip(ages); // ("A", 10), ("B", 20) - stops at the shorter sequence

Zip combines two sequences element-by-element. Enumeration ends as soon as either sequence runs out of elements.

Chunk - split into batches

var data = Enumerable.Range(1, 10);
foreach (var chunk in data.Chunk(3))
{
  Console.WriteLine(string.Join(",", chunk));
}

Chunk splits a sequence into arrays of a fixed size (the last chunk can be smaller).

ToLookup - "dictionary key -> many values"

var words = new[] { "cat", "car", "dog" };
var lookup = words.ToLookup(w => w[0]);
Console.WriteLine(string.Join(",", lookup['c'])); // cat,car

ToLookup materializes a lookup structure where each key maps to a collection of values. Conceptually, it’s like a Dictionary<TKey, List<TValue>>, but with a dedicated API and behavior.

Repeat - repeat a value N times

var zeros = Enumerable.Repeat(0, 5); // 0,0,0,0,0
Repeat produces a sequence with the same value repeated a specified number of times.

Flags in enums

Flags can be combined using bit masks (powers of two).

Adding [Flags] improves both semantics and formatting - for example, composite values are displayed as comma-separated names instead of raw integers.
[Flags]
public enum FileAccess
{
   None  = 0,
   Read  = 1,
   Write = 2,
   Exec  = 4,
   ReadWrite = Read | Write
}
You can represent combinations of enum values:
var perm = FileAccess.Read | FileAccess.Exec;
bool canWrite = (perm & FileAccess.Write) != 0; // false

Advanced collection types: Immutable, Frozen, Concurrent

These are different collection families designed for different scenarios.

Frozen (FrozenDictionary, FrozenSet) - "build once, read forever"

Frozen collections are optimized for read-heavy workloads. You create them once, and after that they do not change.

var frozen = new Dictionary<int, string>
{
   [1] = "a"
}
.ToFrozenDictionary();

Immutable (System.Collections.Immutable) - persistent data structures

//Immutable collections are persistent: any "mutation" returns a new collection instance.
var imm = ImmutableList<int>.Empty.Add(1).Add(2);
var imm2 = imm.Add(3); // imm is unchanged

Concurrent - mutation-friendly collections for parallel access

Concurrent collections are designed for multi-threaded access with mutations. They provide thread-safe, typically atomic operations, but they do not give you full "transactional" semantics across multiple operations.

BlockingCollection - producer/consumer with blocking and bounded capacity

BlockingCollection<T> supports producer-consumer patterns with blocking and optional capacity limits. In modern codebases, it’s often replaced by Channel<T>, depending on the use case.

Pattern matching is multi-dimensional

Don’t overlook this - pattern matching is a powerful tool borrowed from functional programming.

Consider the following code. It is perfectly valid C#:

var data = new[] { 2, 5, 1, 0 };
if (data is [_, > 0, ..] or [.., <= 0, _])
{
   Console.WriteLine("condition met");
}

How to read it:

  • [_, > 0, ..] - an array with at least 2 elements where the second element is greater than 0
  • [.., <= 0, _] - an array where the second-to-last element is less than or equal to 0 .. - "the rest" (a slice) of any length

Another example:

bool EndsWithSingleIntList(List<List<int>> lol) => lol is [.., [_]];
  • [_] - a list with exactly one element

Can you "get an object back" from the GC?

GC is not an archive. If an object has been collected, its memory has been freed or reused, and you no longer have a reference - you cannot bring it back.

What actually exists in practice:

A) WeakReference - a reference without keeping the object alive


public static class Program
{
    static WeakReference<object> weak;
    public static void method()
    {
        weak = new WeakReference<object>(new object());
    }
    
    public static void Main()
    {
        method();
        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine(weak.TryGetTarget(out _)); //false
    }
}

A WeakReference lets you reference an object without preventing it from being collected. If the object survives, you can still retrieve it. If it’s gone - it’s gone.

B) "Resurrection" in a finalizer

It is technically possible to resurrect an object from a finalizer, but it is almost always a bad idea. It breaks predictability and makes GC behavior harder to reason about.

Thanks for reading all the way to the end - and I hope you won’t be asking these questions in interviews :)

Vladyslav Furdak

Vladyslav Furdak

Senior Software Engineer & Microsoft MVP with 15+ years building scalable backend systems. Founder of Ukraine's largest .NET community. Writes about clean architecture, design patterns, EF Core, Azure, and engineering career growth.

Follow

Never Miss an Article

Get practical .NET tips and architecture insights delivered to your inbox when new articles are published.