Under the Hood: How Do C# Top-Level Statements Actually Work?
I taught a C# programming class for a company just outside of Boston. The company was making a move towards .NET Core and C#, but a good chunk of their coders were Python experts not C# experts. They all knew how to program but moving over to C# and the world of Visual Studio was causing them headaches. So they brought me in to help them get up to speed.
These students peppered me with non-stop questions about C# and .NET Core and ASP.NET Core. Since they already knew how to write code, a lot of these questions were really insightful and eye-opening and made me realize how much stuff we know about what we do that we just never think about.
One of these questions was about Program.cs and C# and the difference between C# written with top-level statements and without top-level statements. The question was basically: "so how does that actually work?" Now that might sound like a real "noob" question -- and it is. But then when I went to start explaining it, I really understood how much magical stuff happens that is hidden from us.
Anyway, this article is inspired by that question.
'Hello,World' with and without C# Top-Level Statements
First, it's probably worth mentioning what C# top-level statements are. They're a feature that was added to C# 9.0 back in November of 2020. My (possibly wrong) understanding of why they were added to the language is so that there was less of a hurdle for people to get started coding with C#.
Let's look at "Hello, World" done in the old way before top-level statements.
using System;
namespace Benday.WithoutTopLevelStatements
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}
If you're a veteran C# programmer, you'll look at this, know exactly what's going on, and your eye will quickly land on the call to Console.WriteLine()
. "Yep. Got it. This is hello world."
But try to look at this with fresh eyes. For such a simple piece of software, there's actually kind of a lot of stuff in here. And if you're reading from top to bottom, you need to get through four different C# statements before you get to the part that actually prints "Hello, world" on the screen.
- using System;
- namespace
- class Program
- static void Main(string[] args)
If you're not familiar with C#, that's four instances of "uhhh what does that mean" before you get to the Console.WriteLine()
.
That's a pretty good barrier to entry for people looking to get started with C#.
Now let's take a look at 'Hello, World' with top-level statements:
Console.WriteLine("Hello, World!");
That's it. One line. Straight to the point.
"But How Does It Work?"
So I showed that student the two different versions. He paused for about 5 seconds ... and then hit me with three more questions.
- "Ok. Sure. It gets rid of a bunch of stuff. But how does it actually work?"
- "Which version is better?"
- "Are there performance differences?"
My reply was something like "Ooof. Dude, you're making me work for my fee!"
First the "how does it work" question. The answer is that the C# compiler makes a bunch of choices for you and adds in the missing stuff.
Next, "which version is better?" It all comes down to readability and maintainability. For small little apps that you don't expect to need to maintain over the long term, the top-level statements version is just fine -- you write your code and you move on. If you expect your application to get complex, then the version without top-level statements has some stuff that can help you stay organized.
Finally, "are there performance differences?" This one I didn't know off the top of my head. I suspected that there would be absolutely no difference but I didn't know for sure.
.NET Intermediate Language (IL) and ildasm
After class, I started thinking about whether there actually would be a difference between the two versions of the code. I also started wondering if I really knew what I was talking about when I said that there would be practically no difference between the two versions. I assumed they'd be just about the same but I didn't know that they'd be the same.
You might have heard the term managed code before in relation to C# and .NET. When you compile your C# code, what gets created is something called .NET Intermediate Language or "IL." (Pronounced, 'eye-ell'.) That IL gets written into your DLLs and EXEs and when you run your .NET applications, that IL is read and run by the Common Language Runtime (CLR).
If we're being 100% honest and transparent, when we compile our C# code, it's actually NOT compiling. It's turning it into this intermediate language which is basically a script. Which then begs the question "is C# actually an interpreted language?" I mean, you can't run IL directly, right? Correct, you can't run IL directly, but C# still isn't interpreted. What happens when you run your .NET applications is that the CLR takes the IL, compiles it into machine code, and then executes the machine code.
That's actually quite a bit of the "secret sauce" that allows .NET Core to be cross-platform. The .NET Runtime (which contains the CLR) has platform-specific versions and it's able to take the IL in your DLLs and run them on Windows, Linux, or Mac.
If you want to view the IL from your DLLs, there's a nifty little tool called ildasm2. To install it, go to your command prompt and type dotnet tool install dotnet-ildasm2 -g
. Once you've done that, you get a command called ildasm (not ildasm2, btw) that will let you extract the IL from a DLL.
Comparing the IL for 'Hello, World'
So to get to the bottom of my student's "how does it work" question, I went home and created two version of 'Hello, World' with and without top-level statements. Then I used ildasm to extract the IL from the DLL.
By the way, if you're interested, I posted all of this to https://github.com/benday-inc/demo-ildasm-top-level-statements.
Here's the IL for the non-top-level statement version:
.class private auto ansi beforefieldinit Benday.WithoutTopLevelStatements.Program extends [System.Runtime]System.Object
{
.method private hidebysig static default void Main(string[] args) cil managed
{
.custom instance void class [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(byte) = ( 01 00 01 00 00 ) // .....
// Method begins at Relative Virtual Address (RVA) 0x2050
.entrypoint
// Code size 13 (0xD)
.maxstack 8
IL_0000: nop
IL_0001: ldstr "Hello, World!"
IL_0006: call void class [System.Console]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ret
} // End of method System.Void Benday.WithoutTopLevelStatements.Program::Main(System.String[])
.method public hidebysig specialname rtspecialname instance default void .ctor() cil managed
{
// Method begins at Relative Virtual Address (RVA) 0x205E
// Code size 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void class [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // End of method System.Void Benday.WithoutTopLevelStatements.Program::.ctor()
} // End of class Benday.WithoutTopLevelStatements.Program
And here's the IL for the version that uses top-level statements:
.class private auto ansi beforefieldinit Program extends [System.Runtime]System.Object
{
.custom instance void class [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // ....
.method private hidebysig static default void <Main>$(string[] args) cil managed
{
// Method begins at Relative Virtual Address (RVA) 0x2050
.entrypoint
// Code size 12 (0xC)
.maxstack 8
IL_0000: ldstr "Hello, World!"
IL_0005: call void class [System.Console]System.Console::WriteLine(string)
IL_000a: nop
IL_000b: ret
} // End of method System.Void Program::<Main>$(System.String[])
.method public hidebysig specialname rtspecialname instance default void .ctor() cil managed
{
// Method begins at Relative Virtual Address (RVA) 0x205D
// Code size 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void class [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // End of method System.Void Program::.ctor()
} // End of class Program
Here's a screenshot of these two IL files side-by-side. The one on the left is the "without" top-level statements version and the one on the right is the "with" top-level statements version. The "without" version is 25 lines long and the "with" version is 24 lines long. Structurally, they're almost exactly the same.
[Click on image for larger view.]
There's only one real difference. The "without" version starts with a nop call. I'm not going to pretend that I 100% understand what's happening in the IL but nop means "no operation". Basically, the equivalent of saying "just hang out for a second and do nothing". After that, nop call, everything else is the same except for some line numbers.
Without Top-Level Statements |
With Top-Level Statements |
IL_0000: nop |
IL_0000: ldstr "Hello, World!" |
IL_0001: ldstr "Hello, World!" |
IL_0005: call void class [System.Console]System.Console::WriteLine(string) |
IL_0006: call void class [System.Console]System.Console::WriteLine(string) |
IL_000a: nop |
IL_000b: nop |
IL_000b: ret |
IL_000c: ret |
|
Summary
So all of that came from basically one student question in a C# course. It was a heck of a good question, actually. In short, top-level statements in C# generate almost exactly the same "executable" output and you can verify it by looking at the generated Intermediate Language (IL) using ildasm. All that stuff that isn't there in the top-level statement version of Hello, World, just gets added in by the compiler.
I hope you found this interesting. I realize that this is some pretty esoteric stuff. But I've gotta say that I love knowing how this stuff actually works.
BTW, if you want to look at the code and the IL output, you can get it here: https://github.com/benday-inc/demo-ildasm-top-level-statements.
About the Author
Benjamin Day is a consultant, trainer and author specializing in software development, project management and leadership.
Posted by Benjamin Day on 02/21/2025