Over the years, in collaboration with the community, the C# team has introduced numerous impressive new syntax features. Some of these simplify coding and significantly reduce keystrokes. Some others pave the way for unique performance enhancements. Let’s explore these changes:
Makes single-line Hello World program possible
Nowadays, single-line C# programs are valid:
1 |
Console.WriteLine("Hello, World!"); |
This is possible thanks to
- C# 9 Top-level statements: This allows the main program to be written without a wrapping
Main()
method or class definition, streamlining simple applications and scripts. - C# 10 Global Using Directives: This feature enables the specification of a set of using directives that are applied globally to all files in a project, eliminating the need to explicitly include them in each file.
global using System;
- C# 10 Implicit using directives: This relieves the omission of repetitive
using
statements for namespaces in every file, instead automatically including them based on project configurations. A file namedYourProjectName.GlobalUsings.g.cs
is generated by the compiler to contains global using directives.
Reference: Modern C# Hello World
Value tuple to handle multiple values in a single variable
C# 7.0 introduced value tuples. Tuples provide a simple and efficient means to return multiple values from a method without the need to define a custom class.
1 2 3 4 5 6 |
(string firstName, string lastName, int birthYear) person = GetPerson(Guid.Empty); if(person.birthYear > 1970) { ... } static (string firstName, string lastName, int birthYear) GetPerson(Guid id) { return new("Bill", "Gates", 1955); } |
The C# compiler compiles tuples to generic structures System.ValueTuple<T1, ..., TN>
.
Reference: C# Value Tuples
Deconstruction to assign multiple variables at once from an object
C# 7.0 also proposed deconstructors to assign multiple variables at once from an object:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var person = new Person() { FirstName = "Bill", LastName = "Gates", BirthYear = 1975 }; // Call the deconstructor to create and initialize 3 variables // in a single statement thanks to deconstruction var (firstName, lastName, birthYear) = person; Console.WriteLine($"FirstName:{firstName} LastName:{lastName} BirthYear:{birthYear}"); class Person { internal string FirstName { get; init; } internal string LastName { get; init; } internal int BirthYear { get; init; } // Here is the deconstructor internal void Deconstruct(out string firstName, out string lastName, out int birthYear) { firstName = FirstName; lastName = LastName; birthYear = BirthYear; } } |
Notice how a tuple can be deconstructed into several variables. In the program below we use the discard character _
to avoid defining a variable for tuple’s lastName
:
1 2 3 |
(string firstName, string lastName, int birthYear) person = GetPerson(Guid.Empty); var (firtName, _, birthYear) = person; // Deconstruct the tuple into 2 variables if(birthYear > 1970) { ... } |
Reference: Deconstruction in C#
Pattern matching to simplify complex expressions
Pattern matching can help simplify complex if-else statements like in this code:
1 2 3 4 5 6 7 8 9 |
public static string WhichShapeWithRelationalPattern(this Shape shape) => shape switch { Circle { Radius: > 1 and <= 10 } => "shape is a well sized circle", Circle { Radius: > 10 } => "shape is a too large circle", Rectangle => "shape is a rectangle", _ => "shape doesn't match ay pattern" }; |
The pattern matching various syntaxes are applicable in numerous situations with:
- type pattern:
if(shape is Circle circle) { ... }
- negated pattern:
if(shape is not null) { ... }
- combinator, parenthesized and relational patterns
1 2 |
public static bool IsLetterOrSeparator(this char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ','; |
First pattern-matching syntaxes were initially introduced in C# 7.0. Since then, nearly every subsequent language version has introduced new pattern-matching expressions.
Reference: C# pattern matching
Index and range to simplify access to elements of a collection
Index ^
and range ..
operators were introduced in C# 8.0 to support more sophisticated and efficient data manipulation, particularly with arrays and other collections. The index operator allows for indexing from the end of a sequence, while the Range operator facilitates slicing operations.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
var arr = new[] { 0, 1, 2, 3, 4, 5 }; Assert.IsTrue(arr.Length == 6); // [^1] means last element // equivalent to [arr.Length - 1] Assert.IsTrue(arr[^1] == 5); Assert.IsTrue(arr[arr.Length - 1] == 5); // [^2] means second last element // equivalent to [arr.Length - 2] and so on Assert.IsTrue(arr[^2] == 4); // [..] means range of all elements Assert.IsTrue(arr[..].SequenceEqual(arr)); // range [1..4] returns {1, 2, 3 } // start of the range (1) is inclusive // end of the range (4) is exclusive Assert.IsTrue(arr[1..4].SequenceEqual(new[] { 1, 2, 3 })); // [..3] returns { 0, 1, 2 } from the beginning till 3 exclusive Assert.IsTrue(arr[..3].SequenceEqual(new[] { 0, 1, 2 })); // [3..] returns { 3, 4, 5 } from 3 inclusive till the end Assert.IsTrue(arr[3..].SequenceEqual(new[] { 3, 4, 5 })); // [0..^0] means from the beginning till the end // It is equivalent to [..] // Remember that the upper bound ^0 is exclusive // so there is no risk of IndexOutOfRangeException here Assert.IsTrue(arr[0..^0].SequenceEqual(arr)); // [2..^2] means [2..(6-2)] means [2..4] Assert.IsTrue(arr[2..^2].SequenceEqual(new[] { 2, 3 })); // [^4..^1] means [(6-4)..(6-1)] means [2..5] Assert.IsTrue(arr[^4..^1].SequenceEqual(new[] { 2, 3, 4 })); |
Reference: C# Index and Range Operators Explained
Use managed pointers ‘ref’ keyword everywhere
Since C# 1.0 there was the ref
keyword that allows to passing of managed pointers to a function:
1 2 3 4 5 |
int i = 0; Fct(ref i); Assert.IsTrue(i == 1); static void Fct(ref int i) { i++; } |
Managed pointers are a unique feature of the .NET runtime, offering substantial advantages:
- It can point toward any kind of memory: a method local variable, a method parameter in or out, a location on the stack, an object on the heap, a field of an object, an element of an array, a string, or a location within a string, unmanaged memory buffer…
- The Garbage Collector is aware of managed pointers and updates them accordingly when the objects they point to are moved.
- They operate quickly.
The primary limitation is that a managed pointer must reside on the thread stack.
C# 7.0 introduced ref local and ref return to use managed pointers as local variables and return values.
1 2 3 4 5 6 7 8 9 10 11 12 |
// ref local int i = 6; ref int j = ref i; j = 7; Assert.IsTrue(i == 7); // ref return ref int k = ref GetRef(); static ref int GetRef() { int[] arr = new int[6]; return ref arr[2]; } |
Then C# 7.2 introduced the concept of ref struct
, a structure type that must reside solely on the stack. The principal use of ref struct
is exemplified by Span<T>
introduced in the same release, which essentially acts as a managed pointer paired with a length, representing a segment of memory. At this point the managed pointer of Span<T>
is a field, but it was a private runtime trick.
Finally, C# 11 allowed for ref
fields. Since then the implementation of Span<T>
can use a ref
field instead of relying on a private runtime trick. More importantly, you can use ref
field within your own ref
struct.
1 2 3 4 5 6 7 |
public readonly ref struct Span<T>{ /// <summary>A byref or a native ptr.</summary> internal readonly ref T _reference; /// <summary>The number of elements this Span contains.</summary> private readonly int _length; ... } |
In modern C#, you can utilize managed pointers across all safe code areas to enhance performance in critical sections.
References:
- Managed pointers, Span, ref struct, C#11 ref fields and the scoped keyword
- Improve C# code performance with Span<T>
Generic Math and static abstract members
C# 11 introduced static abstract members, enabling the implementation of the .NET Generic Math library in .NET 7.0. This library allows to perform math operations without specifying the exact type involved. For example, the method Middle<T>()
generalizes the process of calculating the midpoint between two numbers. The sample program below uses it to compute midpoint of both int
and float
.
1 2 3 4 5 6 7 8 |
using System.Numerics; Assert.IsTrue(Middle(1, 3) == 2); Assert.IsTrue(Middle(1, 1.5) == 1.25); static T Middle<T>(T a, T b) where T: INumber<T> { return (a + b) / (T.One + T.One); } |
The method Middle<T>()
constraints the generic type parameter T
to be a INumber<T>
. The expression T.One
uses a static property of the interface INumber<T>
. Several other interfaces that rely on C# static abstract members were introduced in the .NET Base Class Library to generalize fundamental mathematical concepts.
Static abstract members can be used in many other situations like for generic parsing with the IParsable<TSelf>
interface:
1 2 3 4 5 6 7 8 |
namespace System { // Summary: Defines a mechanism for parsing a string to a value. // TSelf: The type that implements this interface. public interface IParsable<TSelf> where TSelf : IParsable<TSelf>? { static abstract TSelf Parse(string s, IFormatProvider? provider); static abstract bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result); } } |
References:
C# record to avoid boilerplate code
C# records, introduced in C# 9.0, are a type declaration that simplifies the creation of immutable objects with value-based identity. Therefore, a record is well suited for data-carrying objects designed to store data without altering it after creation and without any associated behavior.
1 2 3 4 5 6 7 8 9 10 11 12 |
var personA = new Person("Bill", "Gates"); Assert.IsTrue(personA.FirstName == "Bill"); Assert.IsTrue(personA.LastName == "Gates"); var personB = new Person("Bill", "Gates"); // Demo of value-based equality Assert.IsTrue(personA == personB); Assert.IsFalse(object.ReferenceEquals(personA, personB)); // Primary constructor syntax on record record Person(string FirstName, string LastName); |
Moreover, records support with-expressions, allowing you to create a new record instance by copying existing records while modifying some of the properties in a non-destructive manner. This makes records a powerful tool for functional programming patterns where immutability is a key concern.
1 2 3 4 5 |
var personA = new Person("Bill", "Gates"); var personB = personA with { FirstName = "Melinda" }; Assert.IsTrue(personB.FirstName == "Melinda"); record Person(string FirstName, string LastName); |
Both record class
and record struct
are available. Using the keyword record
by itself refers to a record class
. The C# compiler automatically generates boilerplate code to support value-based equality along with other features such as read-only properties and a deconstruction method.
Record classes support inheritance. Generic records are also possible. Overall, records in C# enhance the readability and maintainability of code, making them particularly effective for data modeling, especially in applications like data transport across service boundaries, where data integrity and consistency are critical.
Reference: C# Record Explained
Raw literal strings to improve string declaration in certain situations
C# 11 introduced raw literal strings to address challenges faced with traditional string literals. Let’s explore an example of a raw string literal with interpolation. Notably, a raw string literal must start and end with at least three double-quote characters """
.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
string sister = "Léna"; string brother = "Paul"; Console.ForegroundColor = ConsoleColor.White; Console.BackgroundColor = ConsoleColor.DarkBlue; Console.WriteLine( $$""" { "sister": "{{sister}}", "brother": "{{brother}}", } """); Console.BackgroundColor = ConsoleColor.Black; Console.ReadKey();/pre> |
We use console coloring to highlight the string obtained at runtime:
The introduction of raw string literals in C# 11 brings several benefits:
- Formatting and Alignment: For instance, the compiler ignores six leading spaces before the
{
character in the literal. - Double Quote Handling: Previously, to include a double quote in a string, you would need to escape it or use verbatim strings. Now, it is seamlessly included.
- Interpolation: This raw string literal uses string interpolation, where the delimiters are doubled (
{{
and}}
) to distinguish from the literal braces, streamlining the process significantly and allowing single braces to be included directly in the string.
Reference: C# 11 Raw String Literals Explained
Plenty of convenient syntaxes
Over the years, the C# team has introduced numerous convenient syntax features in addition to the major improvements we’ve discussed.
null coalescing operator
To quickly test for nullity. For example this code:
1 2 |
if(app != null && app.Context != null && app.Context.Name != null) { return app.Context.Name; } return ""; |
can be simplified to:
1 |
return app?.Context?.Name ?? ""; |
Expression-bodied members
Introduced in C# 6.0, expression-bodied members enable concise one-liner definitions for methods, properties, indexers, and event accessors using a lambda-like syntax, streamlining the code significantly.
1 |
public int Add(int x, int y) => x + y; |
Auto-property initializers
Introduced in C# 6.0, this feature allows the initialization of auto-implemented properties directly within their declarations, simplifying the syntax for assigning default values to properties.
1 |
public int Url { get; set; } = "www.ndepend.com"; |
readonly struct
The readonly
modifier in C# applied to a struct ensures that the structure is immutable. It means that its members cannot be modified after the instance is created. This can enhance performance and maintainability by preventing unintentional state changes.
1 2 3 4 5 |
public readonly struct Point { public Point(double x, double y) { X = x; Y = y; } public double X { get; } public double Y { get; } } |
Local function
Local functions in C# are methods defined within the scope of another method. It allows for organized and encapsulated code that is easier to read and maintain.
1 2 3 4 5 6 7 8 |
static int Fibonacci(int n) { return Fib(n); // Call the local function int Fib(int num) { // Local function if (num <= 1) return num; return Fib(num - 1) + Fib(num - 2); } } |
Default interface methods
Introduced in C# 8.0, this feature allows you to provide default implementations for methods within an interface. This eases backward compatibility by enabling the addition of new methods without requiring all implementing classes to define an implementation.
1 2 3 4 5 6 7 |
public interface IGreeting { void Greet() => Console.WriteLine("Hello, World!"); // Default method implementation } public class Friendly : IGreeting { // No need to implement Greet unless custom behavior is needed } |
File Scoped Types
C# 11 introduced the file-scoped types feature, which allows a type definition to be restricted to the current file using a new file modifier. This enables multiple classes with the same name (namespace.name
) to coexist within a single project. The following project demonstrates this with two classes both named ConsoleApp1.Answer
:
Conclusion
Over two decades since C#’s initial release, Microsoft is more committed than ever to enhancing the language, runtime, and platform. This dedication makes .NET a favored choice among developers for programming. The community can take comfort in knowing that their current expertise will remain relevant for many years to come.