The Features of C# 9 That Will Make Your Life Easier [Snippets]
C# is a language that Microsoft created for their own projects. We analyze the additional features in C# 9 and recall the old features that can also be useful.
Join the DZone community and get the full member experience.
Join For FreeThis article is a sort of 'cheat sheet' for developers on the most useful features of C# 9 and several functions from previous versions. With each new version of C#, its developers strive to make the programming process more convenient and concise. This time around, most of the attention was paid to changes in object properties, the new Record
type, and more, but first things first.
C# is a programming language that Microsoft created for their own projects. Its syntactic capabilities have something in common with Java and C++. In 2000, the company's engineers developed the ASP.NET active server page technology, which allowed databases to be tied to web applications. ASP.NET itself was written in C#. The ability to build flexible and scalable applications in the future is one of the nice advantages of C#. Products can also be very different — from games to web services.
Since 2017, its developers have been announcing a new version of C# year after year. Early on, it was presented as an only object-oriented language, then, in recent years, they added features that brought a functional approach to it. Thus, developers have more variability in solving problems.
In this article, we will analyze the additional features in C# 9 and recall the old features that can also be useful.
Init-Only Setter
This setter has been lacking for a long time. It was added so that it does not limit the user in their ability to create objects. Init-only setter allows you to initialize properties only in the class constructor or use the object initialization block. None of the previously presented setters could implement this functionality.
xxxxxxxxxx
public string FirstName { get; init; }
public User(string firstName)
{
this.FirstName = firstName;
}
public void ChangeName(string name)
{
//Error: CS8852
this.FirstName = firstName;
}
var user = new User() { FirstName = "Name" };
//Error: CS8852
user.FirstName = "NewName";
What other important features came with the Init-only setter? If your object has, for example, such properties, it will not be mutable. That means you can only change the object at the stage of its creation. Object initializers and constructors are good for creating nested objects where an entire tree of objects is created in a single go. They free the user from writing numerous boilerplates. It is enough to prescribe certain properties.
Deconstruct Feature
A deconstructor implies decomposing an object. It allows you to immediately decompose an object in one line into several variables, declare them in scope, and assign certain values. How do you implement this feature? In the class in which you want this feature to appear, define a deconstructor method. Then set the out parameters in these methods and cast them to the scope that caused this deconstruction. Deconstructors can be overridden. They can have two or more parameters.
A deconstructor implies decomposing an object. It allows you to immediately decompose an object in one line into several variables, declare them in scope and assign certain values. How do you implement this feature? In the class in which you want this feature to appear, define a deconstructor method. Then set the out parameters in these methods and cast them to the scope that caused this deconstruction. Deconstructors can be overridden. They can have two or more parameters.
xxxxxxxxxx
var user = new User() { FirstName = "FirstName", MiddleName = "MiddleName", LastName = "LastName" };
var (firstName, lastName) = user;
public void Deconstruct(out string firstName, out string lastName)
{
(firstName, lastName) = (FirstName, LastName);
}
public void Deconstruct(out string firstName, out string middleName, out string lastName)
{
(firstName, middleName, lastName) = (FirstName, MiddleName, LastName);
}
var (firstName, _, lastName) = user;
Imagine a situation: there is one deconstructor with three arguments, and we only need the first and the last. There are two options for how to get them. The first is to overload the deconstructor and make it with two arguments. The second is to use the (_
) operator. It will allow us to highlight the variable that we are not passing to the external context. Thus, we will be able to get only the necessary data and not overload the deconstructors.
Indices and Ranges
The index relative to the end of the code array was added in the C# 7 version. This is a handy feature when you need to work, not with the beginning of an array, but with the end. Ranges make it easy to get a sub-array from a shared array.
xxxxxxxxxx
var array = new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 }
array[^1] // 0
Range range = ..6; // 0,1,2,3,4,5
Range range = 6..; //6,7,8,9,0
Range range = ^2..^0; //9,0
New Operator
When creating an object, we have the left and right sides of the expression. On the left, we declare the type and name of the variable, and, on the right, we directly create an object. With the var
statement on the left, we can omit the type. During compilation, it will pull up from the right side and replace var
with the desired data type. This is how var
works. Now with the new
operator, we only specify the expected data type only on the left side of the expression and omit it on the right.
Let's say we have a user that is created with three different constructors: empty, with two arguments and using the code initialization block. If we specify the type name on the left side of the expression, we can use the new()
operator so as not to indicate the type on the right side.
Also, see how it is now easier to create a Dictionary using the new operator:
xxxxxxxxxx
User xu = new();
User yu = new("FirstName", "LastName");
User zu = new() { FirstName = "FirstName", LastName = "LastName" };
Dictionary<int, User> lookup = new()
{
[1] = new(),
[2] = new(),
[3] = new(),
[4] = new()
}
Local Functions
Local functions were introduced in #8. They can be declared inside a function, in an expression, or in a constructor. It is convenient to work with these functions in recursion when you need to calculate the degree, factorial, or find the Fibonacci number. In the new version, local functions have been slightly updated. Attributes can now be used with them.
If a method performs several tasks, when you look at it, sometimes you will not immediately understand what it does and what parts it consists of. Local functions allow you to take out the blocks of the algorithm and somehow designate them (give them a name). When our blocks of code are split into local methods and names are given to these methods, it's easier to navigate the code. If local functions are regularly repeated, I advise you to move them to another class or method and then use them separately.
xxxxxxxxxx
public void Get(User[] users)
{
//Processing logic ...
var result = FactorialCalc(somenumber);
//Processing logic ...
int FactorialCalc(int number) => number == 1 ? 1 : number * FactorialCalc(number-1);
}
From personal experience, it is still difficult for me to imagine the use of local functions on a project scale. I think that when developing, you need to keep the project in a consistent style. If it grows over time, local functions can make your code harder to read. In small and isolated solutions, maybe these features will come in handy. For example, Azure Functions, AWS Lambdas, and background workers. But I see no problem in using a private method or extension method instead of a local function.
Top-Level Statement
A top-level statement allows you to remove the clutter of unnecessary code. When creating a console application, we have a program .cs file with a standard set of code (array using, namespace, class program, Main method). In fact, they are not informative, because you still have to write all the code inside the Main function. When a newbie opens a console application and sees many lines of code, he gets confused and doesn't understand how they work. Fortunately, the .NET developers simplified this point. Now the user writes the application from scratch. Neither namespaces, nor programs, nor Main interfere. There is no need to waste time maintaining this cumbersome infrastructure. However, this applies to small test tasks that do not require a deep understanding of the platform.
using System;
namespace C9.features
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World");
}
}
}
using System;
Console.WriteLine("Hello World");
Record Type Feature: The Main C# 9 Feature
It is a new data type that borrows several features from value and reference types. The Record is a reference type. However, no object reference is passed during an assignment. The object is copied, like for value types. The record keyword gives this class additional behavior. The major difference is that the Record type has a structured approach to comparing objects. If we have two instances of a class and we compare them, then this happens by reference, not by its properties. While with Record, they are compared by the values of the fields that are inside. Also, when you declare a Record, it creates a set of methods under the hood.
What is already implemented in the Record
type:
- Redefined
GetHashCode
andCopy
andClone
methods. - Overridden by
ToString
. - Have a short way of writing.
- Have Deconstruction by default.
- It is possible to use the new keyword
with
when copying.
Records initialization
Let's see what the Record
declaration looks like. There is an access identifier — public. The class is now replaced with the keyword record, followed by its name. Reminiscent of a constructor that takes two arguments. But what will it all turn into then? We will have two fields FirstName
and LastName
, which will have getters and Init-only setters. Here, we cannot use the object initialization block, because, under the hood, we have overridden the constructor. We can no longer use the default constructor. Now you need to declare it or use the default constructor.
xxxxxxxxxx
public record User(string FirstName, string LastName);
public record User
{
public string FirstName { get; init; }
public string LastName { get; init; }
public User(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName);
}
var user = new User("FirstName", "LastName");
//CS7036 There is no argument given that corresponds to the required.
var user = new User { FirstName = "FirstName", LastName = "LastName" };
public record User(string FirstName = null, string LastName = null);
var user1 = new User("FirstName", "LastName");
Let's compare the two records. You can see that the FirstName
and LastName
of these objects are the same. Therefore, when comparing it returns true. This happens based on the comparison of field values, not references, as in the classes. Let's create the same user class. When comparing two objects with different references and the same values, we get false.
xxxxxxxxxx
public record User(string FurstName = null, string LastName = null);
var user1 = new User("FirstName", "LastName");
var user2 = new User("FirstName", "LastName");
Console.WriteLine(user1 == user2); //return true;
class User
{
public string FirstName { get; init; }
public string LastName { get; init; }
public User(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName);
}
var user1 = new User("FirstName", "LastName");
var user2 = new User("FirstName", "LastName");
Console.WriteLine(user1 == user2); //return false
Since ToString
is now redefined: when outputting Record
, a construction with all the ‘internals’ of an object is obtained: the type name, property, and nothing needs to be redefined.
xxxxxxxxxx
public enum CustomEnum
{
State1,
State2
}
public record Record(string Name, string Description, CustomEnum CustomEnum);
var record1 = new Record("Record Name", "Record Description", CustomEnum.State2);
Console.WriteLine(record1); // Record { Name = Record Name, Description = Record Description, CustomEnum = State2 }
We intentionally made records immutable. Instead, we create a new instance with different values. With-expressions are already included here.
With-Expressions
When we create a user's Record using a short type, all of our properties have getters and Init-only setters. What does this mean? This means that the properties will only change during the creation of the object. It rarely works out that one object lives quietly in the application all the time. We need to change something in it. For this, we created the With-expressions construction. They use the syntax of an object initializer and show what exactly is different in the new object from the old one. At its core, With-expression is copying an object like a value type. During copying, it makes it possible to change the values of some object properties that should apply to a new variable, while not affecting the value of an existing variable. This function allows us to change fields and write them to a new object.
xxxxxxxxxx
public record User(string FirstName, string LastName);
var user = new User("FirstName", "LastName");
var newUser = user with { FirstName = "FirstName" };
Consolt.WriteLine(newUser); // User {FirstName = New Name, LastName = LastName }
Here, we have a user with two fields — FirstName
and LastName
. We assigned them some values and now we want to write/copy the user to a new variable and then make changes. To do this, after the user, we write the With keyword, and then we can change any fields.
Compared to the previous two versions, the update to C# 9 hasn't been all that enormous. I can draw the following analogy: if they started the car earlier, then its separate mechanisms are already being completed. Somewhere tweaked the running gear, somewhere — repaid the engine. These features are worth trying on the project, at least in order to understand if they fit.
Opinions expressed by DZone contributors are their own.
Comments