Although it’s already over 15 years since the original release of C#, the language doesn’t feel that old. One reason being it has been updated on a regular basis.
Every two or three years, a new version was released with additional language features. Since the release of C# 7.0 in the beginning of 2017, the cadence has further increased with minor language versions.
Within a year’s time, three new minor language versions were released (C# 7.1, 7.2 and 7.3).
Read more at C# 7.1, 7.2 and 7.3 - New Features (Updated).
If we were to look at the code written for C# 1.0 in 2002, it would look much different from the code we write today.
Most of the differences result from using language constructs which didn’t exist back then. However, along with language development, new classes were also added to the .NET framework which take advantage of the new language features. All of this makes the C# of today much more expressive and terser.
Let’s take a trip into history with an overview of the major versions of C#.
For each version, we will inspect the most important changes and compare the code that could be written after its release, to the one that had to be written before. By the time we reach C# 1.0, we will hardly be able to recognize the code as C#.
C# Versions and Evolution

C# 7.0
At the time of writing, the latest major language version is 7.0. With its release in 2017, it’s still recent, therefore its new features aren’t used often.
Most of us are still very used to writing C# code without the advantages it brings.
The main theme of C# 7.0 was pattern matching which added support for checking types in switch statements:
switch (weapon)
{
case Sword sword when sword.Durability > 0:
enemy.Health -= sword.Damage;
sword.Durability--;
break;
case Bow bow when bow.Arrows > 0:
enemy.Health -= bow.Damage;
bow.Arrows--;
break;
}
There are multiple new language features used in the above compact piece of code:
- The case statements check for the type of the value in the weapon variable.
- In the same statement, I declare a new variable of the matching type which can be used in the corresponding block of code.
- The last part of the statement after the when keyword specifies an additional condition to further restrict code execution.
Additionally, the is operator was extended with pattern matching support, so it can now be used to declare a new variable similar to case statements:
if (weapon is Sword sword)
{
// code with new sword variable in scope
}
In earlier versions of the language without all these features, the equivalent block of code would be much longer.
if (weapon is Sword)
{
var sword = weapon as Sword;
if (sword.Durability > 0)
{
enemy.Health -= sword.Damage;
sword.Durability--;
}
}
else if (weapon is Bow)
{
var bow = weapon as Bow;
if (bow.Arrows > 0)
{
enemy.Health -= bow.Damage;
bow.Arrows--;
}
}
Several other minor features were added in C# 7.0 as well. We will mention only two of them:
(i) Out variables allow declaration of variables at the place where they are first used as out arguments of a method.
if (dictionary.TryGetValue(key, out var value))
{
return value;
}
else
{
return null;
}
Before this feature was added, we had to declare the value variable in advance:
string value;
if (dictionary.TryGetValue(key, out value))
{
return value;
}
else
{
return null;
}
(ii) Tuples can be used to group multiple variables into a single value on-the-fly as needed, e.g. for return values of methods:
public (int weight, int count) Stocktake(IEnumerable<IWeapon> weapons)
{
return (weapons.Sum(weapon => weapon.Weight), weapons.Count());
}
Without them, we had to declare a new type to do that even if we only needed it in a single place:
public Inventory Stocktake(IEnumerable<IWeapon> weapons)
{
return new Inventory
{
Weight = weapons.Sum(weapon => weapon.Weight),
Count = weapons.Count()
};
}
To learn more about the new features of C# 7.0, check my article C# 7 – What’s New from a previous edition of the DNC Magazine. To learn more about the minor editions of C# 7, check my article C# 7.1, 7.2 and 7.3 – New Features (Updated) in the DNC Magazine
C# 6.0
C# 6.0 was released in 2015. It coincided with the full rewrite of the compiler, codenamed Roslyn. An important part of this release was the compiler services which have since then become widely used in Visual Studio and other editors:
- Visual Studio 2015 and 2017 are using it for syntax highlighting, code navigation, refactoring and other code editing features.
- Many other editors, such as Visual Studio Code, Sublime Text, Emacs and others provide similar functionalities with the help of OmniSharp, a standalone set of tooling for C# designed to be integrated in code editors.
- Many third-party static code analyzers use the language services as their basis. These can be used inside Visual Studio, but also in the build process.
To learn more about Roslyn and the compiler services, check my article .NET Compiler Platform (a.k.a. Roslyn) – An Overview in the DotNetCurry (DNC) Magazine
There were only a few changes to the language. They were mostly syntactic sugar, but many of them are still useful enough to be commonly used today:
- Dictionary initializer can be used to set the initial value for a dictionary:
var dictionary = new Dictionary<int, string>
{
[1] = "One",
[2] = "Two",
[3] = "Three",
[4] = "Four",
[5] = "Five"
};
Without it, the collection initializer had to be used instead:
var dictionary = new Dictionary<int, string>()
{
{ 1, "One" },
{ 2, "Two" },
{ 3, "Three" },
{ 4, "Four" },
{ 5, "Five" }
};
- The nameof operator returns the name of a symbol:
public void Method(string input)
{
if (input == null)
{
throw new ArgumentNullException(nameof(input));
}
// method implementation
}
It’s great for avoiding the use of strings in code which can easily go out of sync when symbols are renamed:
public void Method(string input)
{
if (input == null)
{
throw new ArgumentNullException("input");
}
// method implementation
}
- Null conditional operator reduces the ceremony around checking for null values:
var length = input?.Length ?? 0;
Not only is there more code required to achieve the same without I, it’s much more likely that we will forget to add such a check altogether:
int length;
if (input == null)
{
length =0;
}
else
{
length = input.Length;
}
- Static import allows direct invoking of static methods:
using static System.Math;
var sqrt = Sqrt(input);
Before it was introduced, it was necessary to always reference its static class:
var sqrt = Math.Sqrt(input);
- String interpolation simplified string formatting:
var output = $"Length of '{input}' is {input.Length} characters.";
It does not only avoid the call to String.Format, but also makes the formatting pattern easier to read:
var output = String.Format("Length of '{0}' is {1} characters.", input, input.Length);
Not to mention that having formatting pattern arguments outside the pattern makes it more likely to list them in the wrong order.
To learn more about C# 6, you can read my article Upgrading Existing C# Code to C# 6.0 in the DotNetCurry (DNC) Magazine.
C# 5.0
Microsoft released C# 5.0 in 2012 and introduced a very important new language feature: async/await syntax for asynchronous calls.
It made asynchronous programming much more accessible to everyone. The feature was accompanied by an extensive set of new asynchronous methods for input and output operations in .NET framework 4.5, which was released at the same time.
With the new syntax, asynchronous code looked very similar to synchronous code:
public async Task<int> CountWords(string filename)
{
using (var reader = new StreamReader(filename))
{
var text = await reader.ReadToEndAsync();
return text.Split(' ').Length;
}
}
Just in case you’re not familiar with the async and await keywords, keep in mind that the I/O call to ReadToEndAsync method is non-blocking. The await keyword releases the thread for other work until the file read completes asynchronously. Only then, the execution continues back on the same thread (most of the times).
To learn more about async/await, check my article Asynchronous Programming in C# using Async Await – Best Practices in the DotNetCurry (DNC) Magazine.
Without the async/await syntax, the same code would be much more difficult to write, and to understand:
public Task<int> CountWords(string filename)
{
var reader = new StreamReader(filename);
return reader.ReadToEndAsync()
.ContinueWith(task =>
{
reader.Close();
return task.Result.Split(' ').Length;
});
}
Notice, how I must manually compose the task continuation using the Task.ContinueWith method.
I also can’t use the using statement anymore to close the stream because without the await keyword to pause the execution of the method, the stream could be closed before the asynchronous reading was complete.
And even this code is using the ReadToEndAsync method added to the .NET framework when C# 5.0 was released. Before that, only a synchronous version of the method was available. To release the calling thread for its duration, it had to be wrapped into a Task:
public Task<int> CountWords(string filename)
{
return Task.Run(() =>
{
using (var reader = new StreamReader(filename))
{
return reader.ReadToEnd().Split(' ').Length;
}
});
}
Although this allowed the calling thread (usually the main thread or the UI thread) to do other work for the duration of the I/O operation, another thread from the thread pool was still blocked during that time. This code only seems asynchronous but is still synchronous in its core.
To do real asynchronous I/O, a much older and more basic API in the FileStream class needs to be used:
public Task<int> CountWords(string filename)
{
var fileInfo = new FileInfo(filename);
var stream = new FileStream(filename, FileMode.Open);
var buffer = new byte[fileInfo.Length];
return Task.Factory.FromAsync(stream.BeginRead, stream.EndRead, buffer, 0, buffer.Length, null)
.ContinueWith(_ =>
{
stream.Close();
return Encoding.UTF8.GetString(buffer).Split(' ').Length;
});
}
There was only a single asynchronous method available for reading files and it only allowed us to read the bytes from a file in chunks, therefore we are decoding the text ourselves.
Also, the above code reads the whole file at once which doesn’t scale well for large files. And we’re still using the FromAsync helper method which was only introduced in .NET framework 4 along with the Task class itself. Before that, we were stuck with using the asynchronous programming model (APM) pattern directly everywhere in our code, having to call the BeginOperation and EndOperation method pairs for each asynchronous operation.
No wonder, asynchronous I/O was rarely used before C# 5.0.
C# 4.0
In 2010, C# 4.0 was released.
It was focused on dynamic binding to make interoperability with COM and dynamic languages simpler. Since Microsoft Office and many other large applications can now be extended by using the .NET framework directly without depending on COM interoperability, we see little use of dynamic binding in most of C# code today.
To learn more about dynamic binding, check my article Dynamic Binding in C# in the DNC Magazine.
Still, there was an important feature added at the same time, which became an integral part of the language and is frequently used today without giving it any special thought: optional and named parameters. They are a great alternative to writing many overloads of the same function:
public void Write(string text, bool centered = false, bool bold = false)
{
// output text
}
This single method can be called by providing it any combination of optional parameters:
Write("Sample text");
Write("Sample text", true);
Write("Sample text", false, true);
Write("Sample text", bold: true);
We had to write three different overloads before C# 4.0 to come as close to this:
public void Write(string text, bool centered, bool bold)
{
// output text
}
public void Write(string text, bool centered)
{
Write(text, centered, false);
}
public void Write(string text)
{
Write(text, false);
}
And even so, this code only supports the first three calls from the original example. Without the named parameters, we would have to create an additional method with a different name to support the last combination of parameters, i.e. to specify only text and bold parameters but keep the default value for the centered parameter:
public void WriteBold(string text, bool bold)
{
Write(text, false, bold);
}
C# 3.0
C# 3.0 from 2007 was another major milestone in the language development. The features it introduced all revolve around making LINQ (Language INtegrated Query) possible:
- Extension methods appear to be called as members of a type although they are defined elsewhere.
- Lambda expressions provide shorter syntax for anonymous methods.
- Anonymous types are ad-hoc types which don’t have to be defined in advance.
All of these contribute towards the LINQ method syntax we are all so used to today:
var minors = persons.Where(person => person.Age < 18)
.Select(person => new { person.Name, person.Age })
.ToList();
Before C# 3.0, there was no way to write such declarative code in C#. The functionality had to be coded imperatively:
List<NameAndAge> minors = new List<NameAndAge>();
foreach(Person person in persons)
{
if (person.Age > 18)
{
minors.Add(new NameAndAge(person.Name, person.Age));
}
}
Notice how I used the full type to declare the variable in the first line of code. The var keyword most of us are using all the time was also introduced in C# 3.0. While this code doesn’t seem much longer than the LINQ version, keep in mind that we still need to define the NameAndAge type:
public class NameAndAge
{
private string name;
public string Name
{
get
{
return name;
}
set
{
name = value;
}
}
private int age;
public int Age
{
get
{
return age;
}
set
{
age = value;
}
}
public NameAndAge(string name, int age)
{
Name = name;
Age = age;
}
}
The class code is much more verbose than we’re used to because of another two features that were added in C# 3.0:
- Without auto-implemented properties I must manually declare the backing fields, as well as the trivial getters and setters for each property.
- The constructor for setting the property values is required because there was no initializer syntax before C# 3.0.
C# 2.0
We’ve made our way to the year 2005 when C# 2.0 was released. Many consider this the first version of the language mature enough to be used in real projects. It introduced many features which we can’t live without today, but the most important and impactful one of them was certainly support for generics.
None of us can imagine C# without generics. All the collections we’re still using today are generic:
List<int> numbers = new List<int>();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);
int sum = 0;
for (int i = 0; i < numbers.Count; i++)
{
sum += numbers[i];
}
Without generics, there were no strongly typed collections in the .NET framework. Instead of the code above, we were stuck with the following:
ArrayList numbers = new ArrayList();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);
int sum = 0;
for (int i = 0; i < numbers.Count; i++)
{
sum += (int)numbers[i];
}
Although the code might seem similar, there’s an important difference: this code is not type safe. I could easily add values of other types to the collection, not only int. Also, notice how I cast the value I retrieve from the collection before using it. I must do that because it is typed as object inside the collection.
Of course, such code is very error prone. Fortunately, there was another solution available if I wanted to have type safety. I could create my own typed collection:
public class IntList : CollectionBase
{
public int this[int index]
{
get
{
return (int)List[index];
}
set
{
List[index] = value;
}
}
public int Add(int value)
{
return List.Add(value);
}
public int IndexOf(int value)
{
return List.IndexOf(value);
}
public void Insert(int index, int value)
{
List.Insert(index, value);
}
public void Remove(int value)
{
List.Remove(value);
}
public bool Contains(int value)
{
return List.Contains(value);
}
protected override void OnValidate(Object value)
{
if (value.GetType() != typeof(System.Int32))
{
throw new ArgumentException("Value must be of type Int32.", "value");
}
}
}
The code using it would still be similar, but it would at least be type safe, just like we’re used to with generics:
IntList numbers = new IntList();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);
int sum = 0;
for (int i = 0; i < numbers.Count; i++)
{
sum += numbers[i];
}
However, the IntList collection can only be used for storing int. I must implement a different strongly typed collection if I want to store values of a different type.
And the resulting code is still significantly less performant for value types because they are boxed to objects before being stored in the collection and unboxed when retrieved.
There are many other features we can’t live without today which were not implemented before C# 2.0:
- Nullable value types,
- Iterators,
- Anonymous methods
- …
Conclusion:
C# has been the primary language for .NET development since version 1.0, but it has come a long way since then. Thanks to the features that were added to it version by version, it’s staying up-to-date with new trends in the programming world and is still a good alternative to newer languages which have appeared in the meantime.
Occasionally, it even manages to start a new trend as it did with the async and await keywords, which have later been adopted by other languages. With support for nullable reference types and many other new features promised for C# 8.0, there’s no fear that the language will stop evolving.
In case you are interested in learning about the upcoming features in C# 8, read www.dotnetcurry.com/csharp/1440/csharp-8-new-features
This article was technically reviewed by Yacoub Massad.
This article has been editorially reviewed by Suprotim Agarwal.
C# and .NET have been around for a very long time, but their constant growth means there’s always more to learn.
We at DotNetCurry are very excited to announce The Absolutely Awesome Book on C# and .NET. This is a 500 pages concise technical eBook available in PDF, ePub (iPad), and Mobi (Kindle).
Organized around concepts, this Book aims to provide a concise, yet solid foundation in C# and .NET, covering C# 6.0, C# 7.0 and .NET Core, with chapters on the latest .NET Core 3.0, .NET Standard and C# 8.0 (final release) too. Use these concepts to deepen your existing knowledge of C# and .NET, to have a solid grasp of the latest in C# and .NET OR to crack your next .NET Interview.
Click here to Explore the Table of Contents or Download Sample Chapters!
Was this article worth reading? Share it with fellow developers too. Thanks!
Damir Arh has many years of experience with software development and maintenance; from complex enterprise software projects to modern consumer-oriented mobile applications. Although he has worked with a wide spectrum of different languages, his favorite language remains C#. In his drive towards better development processes, he is a proponent of Test-driven development, Continuous Integration, and Continuous Deployment. He shares his knowledge by speaking at local user groups and conferences, blogging, and writing articles. He is an awarded Microsoft MVP for .NET since 2012.