We've already explored PHP's questionable decisions, JavaScript's complicated relationship with reality, and Python's talent for producing beautifully formatted mistakes.
Now it's C#'s turn.
And C# is a different kind of beast.
Unlike some of its more chaotic cousins, C# has always presented itself as a serious, carefully engineered programming language.
Strong typing. Managed memory. A sophisticated compiler. A comprehensive runtime. The kind of language that wears a suit to work.
It also has some really weird behavior hiding beneath that professional exterior.
But before we get into that, a little history.
C# arrived in 2002 as part of Microsoft's original .NET Framework, an ecosystem closely associated with Windows and Microsoft's proprietary development tools.
For years, C# was the language many developers associated with enterprise Windows applications, corporate IT departments, and Visual Studio.
Then something unexpected happened.
Microsoft embraced open source.
.NET Core arrived in 2016 as an open-source, cross-platform implementation. In 2020, .NET 5 dropped the "Core" name, establishing the modern .NET platform.
Today, C# runs on Linux, macOS, Windows, and plenty of places its original developers probably never imagined. Steve Ballmer would turn in his grave if he was already dead.
The full story of how Microsoft's development ecosystem went from Windows-centric and largely proprietary to open-source and cross-platform deserves its own article.
We'll get to that.
For now, let's explore five ways C# can make perfectly valid code do something completely unexpected.
1. A Loop That Remembers the Wrong Number
Let's start with something innocent.
You want to create three actions, each printing a different number.
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions)
{
action();
}
What would you expect?
0
1
2
Reasonable.
Here's what you actually get:
3
3
3
Obviously.
Every action prints 3, even though the loop only ran for values 0, 1, and 2.
The culprit is closure capture.
When you create a lambda expression referencing a local variable, C# captures the variable, not a snapshot of its value.
All three lambdas reference the same i.
And by the time you invoke them, the loop has finished, leaving i equal to 3.
The fix is to create a new variable inside the loop:
for (int i = 0; i < 3; i++)
{
int captured = i;
actions.Add(() => Console.WriteLine(captured));
}
Now each lambda captures a different variable.
But here's the particularly interesting part.
Modern C# handles foreach differently.
var actions = new List<Action>();
foreach (var i in new[] { 0, 1, 2 })
{
actions.Add(() => Console.WriteLine(i));
}
This prints:
0
1
2
Since C# 5, each iteration of a foreach loop gets its own iteration variable for closure capture.
But for loops retain their original behavior.
Same language. Similar loops. Different closure semantics.
The lesson: Closures capture variables, not values, and apparently the exact type of loop matters more than you'd expect.
2. async Doesn't Mean What You Think It Means
C# has excellent support for asynchronous programming.
The async and await keywords make it possible to write asynchronous operations that look almost like ordinary sequential code.
But there's a common misunderstanding.
Marking a method async doesn't automatically make it run on another thread.
In fact, an async method begins executing synchronously.
Let's demonstrate.
var gate = new TaskCompletionSource<bool>();
async Task DoWork()
{
Console.WriteLine("Started");
await gate.Task;
Console.WriteLine("Resumed");
}
var work = DoWork();
Console.WriteLine("Caller continues");
gate.SetResult(true);
await work;
Output:
Started
Caller continues
Resumed
Notice that "Started" appears before "Caller continues".
Even though DoWork() is async.
That's because an async method executes normally until it encounters an await whose operation hasn't completed.
Only then does it suspend and return control to the caller.
If the awaited operation is already complete, the method may continue executing synchronously.
And if there's no await at all, the method can execute entirely synchronously.
Which makes async a rather misleading name if you're expecting automatic background execution.
It enables asynchronous control flow. It doesn't magically create a new thread.
The lesson: async is about how execution can be suspended and resumed, not necessarily where that execution happens.
3. C# Lets You Store an Integer in a String Array. Until You Try.
C# is strongly typed.
The compiler is supposed to protect us from putting the wrong kinds of things into the wrong places.
And generally, it does a pretty good job.
Consider:
string[] names = new string[3];
object[] objects = names;
This compiles.
No warnings. No complaints.
A string array can be assigned to a variable of type object[].
That's because C# arrays are covariant for reference types.
Since string derives from object, a string[] can be treated as an object[].
So far, so good.
Now let's try this:
objects[0] = 42;
The compiler accepts it.
After all, an integer can be boxed as an object.
But at runtime:
System.ArrayTypeMismatchException
Congratulations.
You've written type-correct code that violates the actual type of the array.
The array is still fundamentally a string[], regardless of the reference used to access it.
The runtime checks assignments to protect the array from incompatible elements.
And it throws when you attempt to store an integer.
The behavior is deliberate and comes from the type system's historical design.
Modern generic collections generally avoid this problem by using different variance rules.
But arrays still have to preserve their existing behavior for compatibility.
The lesson: Static type safety doesn't mean every type-related problem can be detected at compile time. Sometimes the runtime has to finish the compiler's job.
4. You Can Call a Method That Changes Nothing
Here's one of my favorites.
C# has value types called structs.
Unlike classes, which are reference types, structs have value semantics.
That means copies can appear in places you might not expect.
Consider this simple counter:
struct Counter
{
public int Value;
public void Increment()
{
Value++;
}
}
Nothing unusual.
Now let's put it inside a class:
class Container
{
public readonly Counter Counter = new();
}
And try to increment it:
var container = new Container();
container.Counter.Increment();
Console.WriteLine(container.Counter.Value);
Expected:
1
Actual:
0
Ok.
You called a method that modifies the counter, but the counter didn't change.
And no exception was thrown.
Here's the trick.
The Counter field is marked readonly.
Because Counter is a mutable struct, the compiler creates a defensive copy before calling its mutating method through that readonly field.
The method increments the copy.
Then the copy disappears.
The original counter remains unchanged.
Your code compiled successfully, executed successfully, and accomplished absolutely nothing.
To avoid this, mutable structs need to be handled carefully. Where appropriate, use an immutable or readonly struct, or use a class when reference semantics are what you actually need.
The lesson: Value types can introduce invisible copies, and mutating an invisible copy is an excellent way to accomplish nothing with confidence.
5. One Exception Can Permanently Poison an Entire Type
Let's finish with something particularly unpleasant.
C# supports static constructors.
They're executed automatically when a type needs to be initialized, typically before its first relevant use.
You can use them to initialize shared state.
But what happens if a static constructor throws an exception?
Consider:
class Configuration
{
static Configuration()
{
throw new Exception("Configuration failed!");
}
public static string Status => "Ready";
}
Now try:
Console.WriteLine(Configuration.Status);
The runtime throws a TypeInitializationException.
That's reasonable. Initialization failed.
But suppose you catch the exception, fix whatever caused the problem, and try again.
Too late.
The runtime won't retry the static constructor.
A static constructor runs at most once per type initialization context.
If it throws, the type remains uninitialized and unusable for the lifetime of that context, ordinarily the application's current runtime lifetime.
Subsequent attempts to access the type fail again.
This is particularly nasty if static initialization depends on something transient, like a configuration file, an external service, or a database connection.
A temporary problem can become a permanent failure for that running application.
The exception may be recoverable in theory.
But the type isn't.
The lesson: Static constructors should be boring, predictable, and extremely unlikely to fail. One bad initialization can leave an entire type unusable.
C# Is Weird. But .NET Might Be Even More Interesting.
Here's what makes C# different from some of the languages we've covered.
Most of these behaviors aren't the result of loose typing or a particularly cavalier attitude toward language design.
They're the consequences of deliberate engineering decisions.
Closures capture variables because that enables useful programming patterns.
Async methods execute synchronously until suspension because asynchronous programming doesn't inherently require additional threads.
Array covariance exists partly for historical compatibility.
Structs use value semantics because value types provide important performance and design benefits.
And static constructors run once because type initialization needs predictable lifecycle guarantees.
Every feature has a rationale.
The weirdness appears when those rationales collide with a developer's perfectly reasonable expectations.
But there's another fascinating story hiding behind C#.
When the original .NET Framework appeared in 2002, Microsoft's development ecosystem was fundamentally different from what it is today.
Windows was central.
Visual Studio was the dominant development experience.
The framework itself was proprietary.
Today, modern .NET is open source, cross-platform, and developed in public.
Even the compiler is open source.
And somewhere along the way, Microsoft managed to create one of the more confusing naming histories in software development.
.NET Framework.
.NET Core.
.NET Standard.
.NET 5.
And yes, they skipped .NET Core 4 partly to avoid confusion with .NET Framework 4.x.
Because apparently the solution to confusing names was more confusing names.
That's a story worth exploring next.
How did Microsoft's proprietary Windows development platform become an open-source, cross-platform ecosystem?
What actually changed?
What's the difference between .NET Framework and modern .NET?
And why did Microsoft decide to open-source something that had been so central to its development strategy?
We'll tackle that in the next installment.
Top comments (0)