Chapter 1A - What's New in Modern C# and .NET
This online-only section covers the following topics:
- Understanding modern .NET
- What's new in modern C# and .NET?
- Performance improvements
- Code simplification
- Null and nullability
- Functional programming
- Practicing and exploring
- Summary
In this online-only section, the goal is to review what is new since C# 6 and .NET Core 1.0 that were released in 2016. Instead of just listing the new features introduced with each version of .NET, we will take a themed approach to make it easier to understand how small individual improvements are supposed to work together holistically.
In 2020, .NET Core was rebranded .NET and the major version number skipped 4 to avoid confusion with legacy .NET Framework 4.x. Microsoft plans on annual major version releases every November from now on, rather like Apple does major version number releases of iOS every September.
The following table shows when recent versions of modern .NET were released, when future releases are planned, and when they reach end of life and are therefore become officially unsupported and will receive no more bug fixes and security updates:
Version | Support | Released | End of life |
---|---|---|---|
.NET 6 | LTS | November 8, 2021 | November 12, 2024 |
.NET 7 | STS | November 8, 2022 | May 14, 2024 |
.NET 8 | LTS | November 2023 | November 10, 2026 |
.NET 9 | STS | November 2024 | May 2026 |
.NET 10 | LTS | November 2025 | November 2028 |
You can check which .NET versions are currently supported and when they will reach endof-life at the following link: https://github.com/dotnet/core/blob/main/releases.md.
.NET releases are either LTS, STS (formerly known as Current), or Preview, as described in the following list:
- Long Term Support (LTS) releases are stable and require fewer updates over their lifetime. These are a good choice for applications that you do not intend to update frequently. LTS releases are supported by Microsoft for 3 years after general availability, or 1 year after the next LTS release ships, whichever is longer.
- Standard Term Support (STS) releases include features that may change based on feedback. These are a good choice for applications that you are actively developing because they provide access to the latest improvements. STS releases are supported by Microsoft for 18 months after general availability, or 6 months after the next release ships, whichever is longer.
- Preview releases are for public testing. These are a good choice for adventurous programmers who want to live on the bleeding edge, or programming book writers who need to have early access to new language features, libraries, and app platforms. Preview releases are not supported by Microsoft but preview or Release Candidate (RC) releases may be declared Go Live, meaning they are supported by Microsoft in production.
STS and LTS releases receive critical fixes throughout their lifetime for security and reliability. You must stay up to date with the latest patches to get support. For example, if a system is running 1.0 and 1.0.1 has been released, 1.0.1 must be installed to get support. End of support or end of life means the date after which bug fixes, security updates, or technical assistance are no longer available from Microsoft.
.NET Runtime versioning follows semantic versioning; that is, a major increment indicates breaking changes, minor increments indicate new features, and patch increments indicate bug fixes.
.NET SDK versioning does not follow semantic versioning. The major and minor version numbers are tied to the runtime version it is matched with. The third number follows a convention that indicates the minor and patch versions of the SDK. The third number starts at 100
for the initial version (equivalent to 0.0
for minor and patch number). The first digit increments with minor increments, and the other two digits increment with patch increments.
Change | Runtime | SDK |
---|---|---|
Initial release | 8.0.0 | 8.0.100 |
SDK bug fix | 8.0.0 | 8.0.101 |
Runtime and SDK bug fix | 8.0.1 | 8.0.102 |
SDK new feature | 8.0.1 | 8.0.200 |
There were many language and library features introduced with C# 6 and .NET Core 1:
- Null-Conditional Operator
- Auto-Property Initializers
- Nameof Expressions
- Expression Bodied Functions and Properties
There were many language and library features introduced with C# 7 and .NET Core 2:
- The Main method, the application entry point, can be marked with the async keyword.
- Inferred tuple names.
- Default expressions.
There were many language and library features introduced with C# 8 and .NET Core 3:
- You can apply readonly to members of a struct.
- You can use ??= to assign the value only if the left-hand operand evaluates to null.
- You can use both
$@"..." and @$ "..." as valid interpolated verbatim strings. - There are more ways to perform pattern matching.
- You can make local functions static.
- You can create and consume async streams, meaning iterators that implement IAsyncEnumerable, not streams that derive from the abstract Stream class. You will see an example in Chapter 4, Benchmarking Performance, Multitasking, and Concurrency.
- You can use await using to work with an asynchronously disposable object that implements the System.IAsyncDisposable interface. There were some important language and library features introduced with C# 9 and .NET 5:
- More pattern matching enhancements like type patterns, parenthesized patterns, use of and, or, and not in patterns, relational patterns with <, >, and so on.
- Support for source code generators. They can only add code, not modify existing code.
There were many language and library features introduced with C# 10 and .NET 6:
- Project templates enable nullability checks by default.
- Project templates enable implicitly globally imported namespaces by default.
- You can define value type records using record struct.
- Constant interpolated strings.
- File-scoped namespace declarations.
- Lambda expressions are easier to write because the compiler can infer a delegate type from the expression.
There were many language and library features introduced with C# 11 and .NET 7:
- You can use newlines in string interpolations.
- You can use static abstract members in interfaces.
- You can define generic attributes.
There were many language and library features introduced with C# 12 and .NET 8:
- Primary constructors.
- Aliasing any type.
- Lambda expression parameter default values.
Performance improvements include:
- Simplifying specifying indexes and ranges.
Indices and ranges enable efficient access to elements and slices of elements with an array:
- Define a position using
System.Index
- Define a slice using
System.Range
Items in an array can be accessed by passing an integer into their indexer, as shown in the following code:
int index = 3;
Person p = people[index]; // fourth person in array
char letter = name[index]; // fourth letter in name
The Index
value type is a more formal way of identifying a position, and supports counting from the end, as shown in the following code:
// two ways to define the same index, 3 in from the start
Index i1 = new Index(value: 3); // counts from the start
Index i2 = 3; // using implicit int conversion operator
// two ways to define the same index, 5 in from the end
Index i3 = new Index(value: 5, fromEnd: true);
Index i4 = ^5; // using the caret operator
The Range
value type uses Index
values to indicate the start and end of its range, using its constructor, C# syntax, or its static methods, as shown in the following code:
Range r1 = new Range(start: new Index(3), end: new Index(7));
Range r2 = new Range(start: 3, end: 7); // using implicit int conversion
Range r3 = 3..7; // using C# 8.0 or later syntax
Range r4 = Range.StartAt(3); // from index 3 to last index
Range r5 = 3..; // from index 3 to last index
Range r6 = Range.EndAt(3); // from index 0 to index 3
Range r7 = ..3; // from index 0 to index 3
Code simplification includes:
- Top-level statements to minimize the amount of code needed in a simple console app.
- Implicitly imported namespaces.
- Removing the need to define indented blocks.
- Simplifying instantiating objects.
- Simplifying string definitions.
- Simplifying how to enforce setting properties.
Before the C# 9 compiler, a console app and its Program.cs
file needed to define a class with a Main
method as its entry point, as shown in the following code:
using System;
namespace HelloCS
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
With C# 9, the top-level statements feature allows the Program
class to be created by the compiler, as shown in the following code:
using System;
Console.WriteLine("Hello World!");
All the boilerplate code to define a namespace
, the Program
class, and its Main
method is generated and wrapped around the statements you write.
Key points to remember about top-level programs include the following:
- There can be only one file like this in a project.
- Any
using
statements must go at the top of the file. - You must have at least one executable statement, like the
Console.WriteLine
statement above, or you will get a compile error because the compiler cannot identify where the statements that need to go inside theMain
method are. This is one reason why the Microsoft project template writesHello World!
to the console instead of just having a comment! - If you declare any classes or other types, they must go at the bottom of the file.
- Although you should name the method
Main
if you explicitly define it, the method is named<Main>$
when created by the compiler.
With .NET 6 and later, Microsoft updated the project template for console apps to use top-level statements by default. It also implicitly imports common namespaces globally by default.
Traditionally, every .cs
file that needs to import namespaces would have to start with using statements to import those namespaces. Namespaces like System
and System.Linq
are needed in almost all .cs
files, so the first few lines of every .cs
file often had at least a few using statements, as shown in the following code:
using System;
using System.Linq;
using System.Collections.Generic;
When creating websites and services using ASP.NET Core, there are often dozens of namespaces that each file would have to import.
C# 10 introduced a new keyword combination and .NET SDK 6 introduced a new project setting that work together to simplify importing common namespaces.
The global using
keyword combination means you only need to import a namespace in one .cs
file and it will be available throughout all .cs
files. You could put global using
statements in the Program.cs
file, but I recommend creating a separate file for those statements named something like GlobalUsings.cs
with the contents being all your global using
statements but nothing else, as shown in the following code:
global using System;
global using System.Linq;
global using System.Collections.Generic;
But rather than create this file yourself, you can get the SDK to create it for you. Any projects that target .NET 6.0 or later, and that therefore use the C# 10 or later compiler, can generate a <ProjectName>.GlobalUsings.g.cs
file in the obj
folder to implicitly globally import some common namespaces like System
, as shown in the following code:
// <autogenerated />
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Threading;
global using global::System.Threading.Tasks;
The specific list of implicitly imported namespaces depends on which SDK you target, as shown in the following table:
SDK | Implicitly imported namespaces |
---|---|
Microsoft.NET.Sdk | System , System.Collections.Generic , System.IO , System.Linq , System.Net.Http , System.Threading , System.Threading.Tasks |
Microsoft.NET.Sdk.Web | Same as Microsoft.NET.Sdk and: System.Net.Http.Json , Microsoft.AspNetCore.Builder , Microsoft.AspNetCore.Hosting , Microsoft.AspNetCore.Http , Microsoft.AspNetCore.Routing , Microsoft.Extensions.Configuration , Microsoft.Extensions.DependencyInjection , Microsoft.Extensions.Hosting , Microsoft.Extensions.Logging |
Microsoft.NET.Sdk.Worker | Same as Microsoft.NET.Sdk and: Microsoft.Extensions.Configuration , Microsoft.Extensions.DependencyInjection , Microsoft.Extensions.Hosting , Microsoft.Extensions.Logging |
To control the implicit generation of this file and to control which namespaces are implicitly imported, you can create an item group in the project file, as highlighted in the following markup:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Using Remove="System.Threading" />
<Using Include="System.Numerics" />
<Using Include="System.Console" Static="true" />
<Using Include="System.Environment" Alias="Env" />
</ItemGroup>
</Project>
The <Using>
element supports attributes to control how the namespace or type is imported, as shown in the following table:
Attribute name | Description |
---|---|
Remove |
The name of a namespace to remove from the auto-generated global using file. |
Include |
The name of a namespace or type to import in the auto-generated global using file. |
Static |
If true , imports the type statically, for example, global using static System.Console; |
Alias |
An alias that can be used instead of the namespace or type, for example, global using Env = System.Environment; |
You can simplify using blocks by removing the curly braces. For example, when working with a disposable resource like a file, as shown in the following code:
using (FileStream file = File.OpenWrite(Path.Combine(path, "file.txt")))
{
...
} // automatically calls Dispose if the file is not null
This could be simplified, as shown in the following code:
using FileStream file = File.OpenWrite(Path.Combine(path, "file.txt"));
...
// automatically calls Dispose at the end of current scope if the file is not null
With C# 9, Microsoft introduced another syntax for instantiating objects known as target-typed new
. When instantiating an object, you can specify the type first and then use new
without repeating the type name, as shown in the following code:
XmlDocument xmlDoc = new(); // target-typed new in C# 9 or later
If you have a type with a field or property that needs to be set, then the type can be inferred, as shown in the following code:
// In Program.cs
Person kim = new();
kim.BirthDate = new(1967, 12, 26); // instead of: new DateTime(1967, 12, 26)
// In a separate Person.cs file or at the bottom of Program.cs
class Person
{
public DateTime BirthDate;
}
Raw string literals are convenient for entering any arbitrary text without needing to escape the contents. They make it easy to define literals containing other languages like XML, HTML, or JSON.
Raw string literals start and end with three or more double-quote characters, as shown in the following code:
string xml = """
<person age="50">
<first_name>Mark</first_name>
</person>
""";
In the previous code, the XML is indented by 13 spaces. The compiler looks at the indentation of the last three double-quote characters, and then automatically removes that level of indentation from all the content inside the raw string literal, as shown in the following markup:
<person age="50">
<first_name>Mark</first_name>
</person>
You can mix interpolated with raw string literals. You specify the number of braces that indicate a replaced expression by adding that number of dollar signs to the start of the literal. Any fewer braces than that is treated as raw content.
For example, if we want to define some JSON, single braces will be treated as normal braces, but the two dollar symbols tell the compiler that any two curly braces indicate a replaced expression value, as shown in the following code:
string json = $$"""
{
"first_name": "{{person.FirstName}}",
"age": {{person.Age}},
};
"""
The required
modifier can be applied to a field or property. The compiler will ensure that you set the field or property to a value when you instantiate it.
For example, you might have two properties, one of which should be required, as shown in the following code:
namespace Packt.Shared;
public class Book
{
public required string Isbn { get; set; }
public string? Title { get; set; }
}
If you attempt to instantiate a Book
without setting the Isbn
property, as shown in the following code:
Book book = new();
book.Title = "C# 11 and .NET 7 - Modern Cross-Platform Development";
Then you will see a compiler error, as shown in the following output:
Error CS9035 Required member 'Book.Isbn' must be set in the object initializer or attribute constructor.
You would therefore have to set the property during initialization, as shown in the following code:
Book book = new() { Isbn = "1234-5678"};
The use of the null
value is so common, in so many languages, that many experienced programmers never question the need for its existence. But there are many scenarios where we could write better, simpler code if a variable is not allowed to have a null value.
The most significant change to the C# 8 language compiler was the introduction of checks and warnings for nullable and non-nullable reference types. "But wait!", you are probably thinking, "Reference types are already nullable!"
And you would be right, but in C# 8 and later, reference types can be configured to no longer allow the null
value by setting a file- or project-level option to enable this useful new feature. Since this is a big change for C#, Microsoft decided to make the feature opt-in.
It will take multiple years for this new C# language compiler feature to make an impact since thousands of existing library packages and apps will expect the old behavior. Even Microsoft did not have time to fully implement this new feature in all the main .NET packages until .NET 6. Important libraries like Microsoft.Extensions
for logging, dependency injections, and configuration were not annotated until .NET 7.
For example, in Microsoft's implementation of the System.String
class, the IsNullOrEmpty
method is annotated to indicate expected nullability. Since the purpose of the method is to return true
only when the value
parameter is either null
or empty, if the method returns false
, the value
must not be null
, so the static compiler analysis can be informed that the parameter will not be null
when the method returns false
, as shown in the following code:
bool IsNullOrEmpty([NotNullWhen(false)] string? value)
During the transition, you can choose between several approaches for your own projects:
- Default: For projects created using .NET 5 or earlier, no changes are needed. Non-nullable reference types are not checked. For projects created using .NET 6 or later, nullability checks are enabled by default, but this can be disabled by either deleting the entry in the project file or setting it to disable.
- Opt-in project, opt-out files: Enable the feature at the project level and, for any files that need to remain compatible with old behavior, opt out. This was the approach Microsoft was using internally while it updated its own packages to use this new feature.
- Opt-in files: Only enable the feature for individual files.
To enable the nullability warning check feature at the project level, add the following to your project file:
<PropertyGroup>
...
<Nullable>enable</Nullable>
</PropertyGroup>
To disable the nullability warning check feature at the project level, add the following to your project file:
<PropertyGroup>
...
<Nullable>disable</Nullable>
</PropertyGroup>
To disable the feature at the file level, add the following to the top of a code file:
#nullable disable
To enable the feature at the file level, add the following to the top of a code file:
#nullable enable
If you enable nullable reference types and you want a reference type to be assigned the null
value, then you will have to use the same syntax as making a value type nullable, that is, adding a ?
symbol after the type declaration.
When defining methods with parameters, it is good practice to check for null
values.
In earlier versions of C#, you would have to write if
statements to check for null
parameter values and then throw an ArgumentNullException
for any parameter that is null
, as shown in the following code:
public void Hire(Person manager, Person employee)
{
if (manager == null)
{
throw new ArgumentNullException(nameof(manager));
}
if (employee == null)
{
throw new ArgumentNullException(nameof(employee));
}
...
}
.NET 6 introduced a convenient method to throw an exception if an argument is null
, as shown in the following code:
public void Hire(Person manager, Person employee)
{
ArgumentNullException.ThrowIfNull(manager);
ArgumentNullException.ThrowIfNull(employee);
...
}
C# 11 previews in early 2022 introduced a new !!
operator that did this for you when you applied the operator as a suffix to parameter names, as shown in the following code:
public void Hire(Person manager!!, Person employee!!)
{
...
}
The if
statement and throwing of the exception are done for you. The code is injected and executes before any statements that you write.
The .NET product team claims to have saved more than 10,000 lines of code throughout the .NET libraries by using this feature. But this syntax is controversial within the C# developer community and unfortunately there were enough complaints during the previews that Microsoft reversed their decision and removed the feature from previews. It is unlikely to return.
The biggest new language feature in C# 9 was records. Sometimes you want to treat properties like readonly
fields so they can be set during instantiation but not after. The new init
keyword enables this. It can be used in place of the set keyword, as shown in the following code:
namespace Packt.Shared;
public class ImmutablePerson
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
ImmutablePerson jeff = new()
{
FirstName = "Jeff", // allowed
LastName = "Winger"
};
jeff.FirstName = "Geoff"; // compile error!
The syntax for defining a record can be greatly simplified using positional data members, as shown in the following code:
// simpler way to define a record
// auto-generates the properties, constructor, and deconstructor
public record ImmutableAnimal(string Name, string Species);
Switch expressions are a more compact switch syntax. For example, a switch statement, as shown in the following code:
Stream? s;
...
string message;
switch (s)
{
case FileStream writeableFile when s.CanWrite:
message = "The stream is a file that I can write to.";
break;
case FileStream readOnlyFile:
message = "The stream is a read-only file.";
break;
case MemoryStream ms:
message = "The stream is a memory address.";
break;
default: // always evaluated last despite its current position
message = "The stream is some other type.";
break;
case null:
message = "The stream is null.";
break;
}
Could be more succinctly expressed as a switch expression, as shown in the following code:
Stream? s;
...
string message = s switch
{
FileStream writeableFile when s.CanWrite
=> "The stream is a file that I can write to.",
FileStream readOnlyFile
=> "The stream is a read-only file.",
MemoryStream ms
=> "The stream is a memory address.",
null
=> "The stream is null.",
_
=> "The stream is some other type."
};
You can provide implementations of members in an interface. This is most useful when you have defined an interface, and in a later version you want to extend it. Normally you would not be able to without breaking any clients that consume your interface, because any types that implement the interface will not provide implementations for the new members. Now you can add new members with implementations in the interface and the types will inherit the default implementations in the interface. This is also useful for interactions with APIs for Android or Swift that also support this functionality.
C# has supported math operations like addition and division using operators like + and / since its first version. But that support was implemented only for the numeric data types that were built into the language like int and double.
What if a developer needs to define a new type of number? They could override the standard operators, but that is a lot of work.
Microsoft has added features like static virtual members in interfaces, checked user defined operators, relaxed shift operators, and an unsigned right-shift operator, which are needed to enable anyone to define new types of number that can implement some new interfaces and then work just like all the built-in number types.
For example, you would implement the System.IAdditionOperators<TSelf, TOther, TResult>
interface in a new number type that implements the +
operator.
As you can imagine, defining your own numeric types is a rare and advanced capability, so I do not cover it in this book. If you are interested in learning more, then I recommend reading the blog article at the following link: https://devblogs.microsoft.com/dotnet/preview-features-in-net-6-generic-math/
You can use file
keyword.
Test your knowledge and understanding by answering some questions, getting some hands-on practice, and exploring with deeper research the topics in this chapter.
Use the web to answer the following questions:
- Which type of .NET release is higher quality, STS or LTS?
- In new .NET projects, nullable checks are enabled. What are two ways to disable them?
- If you define any types in a top-level program, where must they go in the
Program
.cs file? - How do you import a class like
Console
so that its static members likeWriteLine
are available in all code files throughout a project? - What is the best new C# 12 language feature?
Use the links on the following page to learn more about the topics covered in this chapter: https://github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-1a---whats-new-in-modern-c-and-net
In this online-only section, you:
- Reviewed some of the new features in the C# compiler and the .NET libraries in modern versions.