.NET C#

Top C# 9.0 Features: A Guide for Developers to Enhance Productivity and Readability

Pinterest LinkedIn Tumblr

Immutable Objects with Init-only Properties

One of the key aspects of good software design is ensuring that objects are immutable, meaning their state cannot be changed after they are created. This helps prevent unintended side effects and makes it easier to reason about the state of an object over its lifetime.

C# 9.0 introduces Init-only properties, which allow you to define properties that can only be set during object initialization. This provides a simple way to make objects immutable and helps ensure the stability of your data.

Here’s an example of how to use init-only properties:

public class User
{
   public string FirstName { get; init; }
   public string LastName { get; init; }
   public int Age { get; init; }
}

In this example, the User class has three init-only properties: FirstName, LastName, and Age. These properties can only be set during object creation and cannot be modified after that. This provides a way to enforce immutability and ensure that the state of the User object remains unchanged over its lifetime.

Trying to update a init-only property results to error. The following example will produce a compilation error because we try to set a property after the object is initialized

var u = new User();
u.FirstName = ""; // compilation error.

Simplified Data Classes with Records

Records allow to quickly and easily define data-oriented classes with minimal code. With records, you can create mutable or immutable classes with concise syntax, making it easier to manage and manipulate data in your code.

One of the biggest advantages of using records is their support for value-based equality. This means that two record instances are considered equal if their properties have the same values. This eliminates the need for manually implementing Equals and GetHashCode methods for custom comparison logic, improving code readability.

Here’s an example to illustrate how records handle equality:

record Person
{
   public string FirstName { get; init; }
   public string LastName { get; init; }
}

record Employee : Person
{
   public int EmployeeId { get; init; }
   public decimal Salary { get; init; }
}

var jim = new Employee { FirstName = "Jim", LastName = "lastname", EmployeeId = 1, Salary = 20000 };
var john = new Employee { FirstName = "John", LastName = "lastname2", EmployeeId = 2, Salary = 25000 };

Console.WriteLine(jim == john); // False

var john2 = new Employee { FirstName = "John", LastName = "lastname2", EmployeeId = 2, Salary = 25000 };
Console.WriteLine(john == john2); // True

In this example, we create two instances of the Employee record. The == operator is used to compare the instances, and the result of the comparison is displayed in the console.

As you can see, the first comparison returns false, because the instances have different values of at least one of their properties. The second comparison returns true, because john and john2 are identical in terms of their properties.

Finally, we can see that inheritance can also be used when using records.

Type Inference with Target-typed new Expressions

Traditionally, when creating an object, you need to specify its type in the new expression. This can become repetitive and make your code more cluttered. With target-typed new expressions, the type can be inferred by the compiler, resulting in cleaner and more readable code.

Here’s an example of how target-typed new expressions can be used in practice:

class User
{
   public string FirstName { get; set; }
   public string LastName { get; set; }
}

// Example 1
// Traditional syntax
User user1 = new User { FirstName = "John", LastName = "Doe" };
// Target-typed new expression
var user2 = new { FirstName = "Jane", LastName = "Doe" };

// Example 2
// Traditional syntax
var user3 = new User { FirstName = "John", LastName = "Doe" };
// Target-typed new expression
User user4 = new();

In the example above, the second object creation uses a target-typed new expression, where the type is inferred from the context. This results in a cleaner and more readable code.

The target-typed new expressions feature is particularly useful when creating instances of simple classes or records, as it eliminates the need to type out the class name each time. This saves time and reduces the likelihood of errors.

Top-level Statements

Before this feature, when writing C# code, it was required to wrap your code within a class, method or property. This increased the amount of unnecessary code needed to be written. With top-level statements, developers can write code outside of a class, method, or property, making it much easier to write and understand.

Here’s an example of how top-level statements can be used in practice:

// Traditional syntax 
class Program { 
   static void Main(string[] args) { 
      Console.WriteLine("Hello World"); 
   } 
}
// Top-level statement 
Console.WriteLine("Hello World");

In the example above, the second code snippet uses top-level statements, eliminating the need for a class, method, or property. This results in less code.

Top-level statements are especially useful for small programs and scripts where the added complexity of a class or method may not be necessary. They also reduce the amount of code needed to be written, saving time during development.

Pattern matching enhancements

C# 9.0 introduced several enhancements to pattern matching, including Type Patterns, Parenthesized Patterns, Conjunctive and Patterns, and Disjunctive or Patterns, Negated not patterns and Relational patterns. In this article, we will explore each of these features in detail and see how they can be used to simplify and improve your code.

Type Patterns

Type Patterns provide a way to match a value against a specific type. You can use this feature to check if a value is of a particular type and then perform some action based on the type. For example, consider the following code:

object obj = "Hello, World!";
if (obj is string str)
{
   Console.WriteLine(str.Length);
}

In this code, we use the is operator to match the obj value against the string type. If the match is successful, the str variable is declared and initialized with the value of obj.

Parenthesized Patterns

Parenthesized Patterns allow you to group multiple patterns together. This allows the developer to override the default precedence. For example, consider the following code:

object obj = (1, "Hello, World!");
if (obj is (int i, string str))
{
   Console.WriteLine(i + ": " + str);
}

In this code, we use the Parenthesized Pattern to match the obj value against a tuple of int and string. If the match is successful, the i and str variables are declared and initialized with the values from the tuple.

Conjunctive And Patterns

Conjunctive and Patterns allow you to match values against multiple patterns. For example, consider the following code:

object obj = (1, 2, 3);
if (obj is (int i, int j, int k) and i + j + k == 6)
{
   Console.WriteLine("The sum of i, j, and k is 6.");
}

In this code, we use the Conjunctive and Pattern to match the obj value against a tuple of int values and also check that their sum is equal to 6. If the match is successful, the code inside the if block is executed.

Disjunctive Or Patterns

Disjunctive or Patterns require only one pattern to match. In general, Disjunctive patterns avoid the need for repeating the same right-hand side in a match several times, by allowing to fold multiple left-hand side patterns into one. For example, consider the following code:

object obj = 10;
if (obj is int i or string str)
{
   Console.WriteLine("obj is either an int or a string.");
}

In this code, we use the Disjunctive or Pattern to match the obj value against either an int or a string. If the match is successful, the code inside the if block is executed.

Negated not patterns

The Negated not Pattern enables developers to match against values that are not of a specific type. To understand this feature, consider the following example:

class Shape
{
   public virtual int Area { get; set; }
}

class Rectangle : Shape
{

   public int Width { get; set; }
   public int Height { get; set; }
   public override int Area { get; set; }
}

class Circle : Shape
{
   public override int Area { get; set; }
   public int Radius { get; set; }
}

public static void Main()
{
   var shape = new Shape();
   if (shape is not Rectangle r)

      Console.WriteLine("Not a rectangle");

   else
      Console.WriteLine("Rectangle with area: " + r.Area);
}

In the above code, we are using the is not syntax to match against a value that is not of type Rectangle. If the shape object is not a Rectangle, the code inside the if block will be executed. If it is a Rectangle, the code inside the else block will be executed and the Rectangle object will be assigned to the r variable.

Relational patterns

Relational patterns allows developers to match values based on specific relational constraints like the input to be less than, greater than, less than or equal, or greater than or equal to a given constant. In a relational pattern, you can use any of the relational operators <><=, or >= but the right-hand part of a relational pattern must be a constant expression. For example, consider the following code:

Console.WriteLine(Classify(18));  // output: Too high
Console.WriteLine(Classify(double.NaN));  // output: Unknown
Console.WriteLine(Classify(3.5));  // output: Acceptable

static string Classify(double measurement) => measurement switch
{
   < -8.0 => "Too low",
   > 15.0 => "Too high",
   double.NaN => "Unknown",
   _ => "Acceptable",
};

Here, the is < -8.0 and > 15.0 is a relational pattern, which matches values based on the relational constraints specified. In the final switch case, it uses the _ wildcard pattern to match any other input and return “Acceptable”.

Combined all patterns together – Example

Here we present an example that combines all previous pattern matching

abstract class Shape
{
   public virtual int Area { get; set; }
}

class Rectangle : Shape
{
   public int Width { get; set; }
   public int Height { get; set; }

   public override int Area { get; set; }
}

class Circle : Shape
{
   public override int Area { get; set; }
   public int Radius { get; set; }
}

Shape shape = new Rectangle { Width = 10, Height = 10 };

if (shape is Circle c && c.Radius > 5 || shape is Rectangle r && (r.Width == 10 || r.Height == 100))
{
   Console.WriteLine("Shape is a circle with radius greater than 5 or a rectangle with width of 10 or height of 100");
}
else if (shape is not Circle && shape is Rectangle r2 && r2.Height < 5)
{
   Console.WriteLine("Shape is not a circle but is a rectangle with height less than 5");
}
else
{
   Console.WriteLine("Shape is not matching any of the patterns");
}

In the above example, we have a base class Shape and two derived classes Circle and Rectangle. We are using pattern matching to check the type of shape object and perform certain actions based on the type and properties of the object.

The first if statement is using Conjunctive and pattern, checking if the shape is a Circle and its Radius is greater than 5, or if the shape is a Rectangle and its Width is equal to 10 or its Height is equal to 100.

The second if statement is using Negated not pattern, checking if the shape is not a Circle and if the shape is a Rectangle with Height less than 5. The else statement is executed if none of the above conditions match.

Covariant Return Types for Interface Methods

This feature allows interface methods to return a more derived type than what is specified in the interface. This means that developers can now write code that is more flexible, scalable and maintainable.

Prior to C# 9.0, when you wanted to implement an interface method, you had to return the exact type specified in the interface. If you wanted to return a derived type, you had to cast the result. This led to a lot of repetitive code and made the code less readable. With the introduction of Covariant Return Types, you can now return a derived type directly, without having to cast the result.

Let’s look at a simple example to understand this feature better. Consider the following interface:

interface IAnimal {
   IAnimal MakeCopy(); 
}

Before C# 9.0, if you wanted to implement this interface with a derived type, you would have to cast the result like this:

class Dog : IAnimal { 
   public Dog MakeCopy() => (Dog)this; 
}

With C# 9.0, you can now return the derived type directly:

class Dog : IAnimal { 
   public Dog MakeCopy() => this; 
}

This makes the code more readable and less repetitive. You can also use this feature when implementing multiple derived types from the same interface.

Lambda Discard Parameters

This feature simplifies the way you write code by allowing you to ignore parameters in a lambda expression. The feature is particularly useful in situations where you don’t need to access the parameters but still need to write a lambda expression.

In previous versions of C#, if you needed to ignore a parameter in a lambda expression, you would have to write code such as _ =>. This can make your code more verbose and harder to read. With the introduction of this feature, you can now simply write _ to ignore the parameter.

Here is an example of how you can use Lambda Discard Parameters to simplify your code:

class Program
{
   static void Main(string[] args)
   {
      Action<int, string> printMessage = (_, message) =>
         Console.WriteLine(message); printMessage(1, "Hello World");
   }
}

In the example above, the Action delegate takes two parameters, int and string, but only the string parameter is used. The int parameter is ignored using the _ syntax.

Another example of where Lambda Discard Parameters can simplify your code is when working with events. Here is an example:

class Program
{
   static void Main(string[] args)
   {
      EventHandler myEvent = (_, __) => 
         Console.WriteLine("Event fired"); // Raise the event 
      myEvent(null, EventArgs.Empty);
   }
}

In the example above, the EventHandler delegate takes two parameters, object and EventArgs, but only the second parameter is used. The first parameter is ignored using the _ syntax.

Recommendations

If you want to deepen your knowledge in C# and .NET, you can refer to the following books:

Head First C#: A Learner’s Guide to Real-World Programming with C# and .NET Core

and C# in Depth: Fourth Edition

* This site contains product affiliate links. We may receive a commission if you make a purchase after clicking on one of these links.

Write A Comment

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.