New Features of C# 10

New Features of C# 10

We are excited to announce that C# 10 has been released as part of .NET 6 and Visual Studio 2022.

Last updated 2/12/2022 10:14 AM
微软中国MSDN
18 min read
Category
.NET
Tags
.NET C# Visual Studio .NET 6 C# 10

(This article takes about 15 minutes to read)

We are pleased to announce that C# 10 has been released as part of .NET 6 and Visual Studio 2022. In this article, we will introduce many new features of C# 10 that make your code more beautiful, expressive, and faster.

Read the Visual Studio 2022 announcement and the .NET 6 announcement to learn more, including how to install.

Global and Implicit Usings

The using directive simplifies how you work with namespaces. C# 10 includes a new global using directive and implicit usings to reduce the number of usings you need to specify at the top of each file.

Global Using Directive

If the keyword global appears before a using directive, the using applies to the entire project:

global using System;

You can use any feature of the using directive with a global using. For example, add a static import of a type and make its members and nested types available throughout the project. If you use an alias in a using directive, that alias also affects your entire project:

global using static System.Console;
global using Env = System.Environment;

You can place global usings in any .cs file, including Program.cs or a specially named file like globalusings.cs. The scope of global usings is the current compilation, generally corresponding to the current project.

For more details, see the global using directive.

Implicit Usings

The implicit usings feature automatically adds common global using directives for the type of project you are building. To enable implicit usings, set the ImplicitUsings property in your .csproj file:

<PropertyGroup>
    <!-- Other properties like OutputType and TargetFramework -->
    <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

Implicit usings are enabled in the new .NET 6 templates. Read more about the .NET 6 template changes in this blog post.

Some specific sets of global using directives depend on the type of application you are building. For example, the implicit usings for a console application or class library differ from those for an ASP.NET application.

For more details, see this implicit usings article.

Combining using Features

Traditional using directives at the top of a file, global using directives, and implicit usings work well together. Implicit usings allow you to include .NET namespaces appropriate for your project type via the project file. Global using directives let you include additional namespaces to make them available throughout the project. Using directives at the top of code files allow you to include namespaces used only by a few files in the project.

Regardless of how they are defined, additional using directives increase the possibility of ambiguity in name resolution. If you encounter this situation, consider adding aliases or reducing the number of imported namespaces. For example, you can replace a global using directive with explicit using directives at the top of a subset of files.

If you need to remove a namespace that is included via implicit usings, you can specify them in the project file:

<ItemGroup>
  <Using Remove="System.Threading.Tasks" />
</ItemGroup>

You can also add namespaces as if they were global using directives by adding Using items to the project file, for example:

<ItemGroup>
  <Using Include="System.IO.Pipes" />
</ItemGroup>

File-Scoped Namespaces

Many files contain code for a single namespace. Starting with C# 10, you can include a namespace as a statement followed by a semicolon and without curly braces:

namespace MyCompany.MyNamespace;

class MyClass // Note: no indentation
{ ... }

This simplifies code and removes one level of nesting. Only one file-scoped namespace declaration is allowed, and it must appear before any type declarations.

For more information about file-scoped namespaces, see the namespace keyword article.

Improvements to Lambda Expressions and Method Groups

We have made several improvements to the syntax and types of lambdas. We expect these to be widely useful, and one driving scenario is making ASP.NET Minimal APIs simpler.

Natural Type for Lambdas

Lambda expressions now sometimes have a "natural" type. This means the compiler can often infer the type of a lambda expression.

Previously, you had to convert a lambda expression to a delegate or expression type. In most cases, you would use one of the overloaded Func<...> or Action<...> delegate types from the BCL:

Func<string, int> parse = (string s) => int.Parse(s);

However, starting with C# 10, if a lambda does not have such a "target type," we try to compute one for you:

var parse = (string s) => int.Parse(s);

You can hover over var parse in your favorite editor and see that the type is still Func<string, int>. Generally, the compiler will use an available Func or Action delegate if a suitable one exists. Otherwise, it will synthesize a delegate type (for example, when you have ref parameters or a large number of parameters).

Not all lambda expressions have a natural type—some simply don't have enough type information. For example, omitting parameter types prevents the compiler from deciding which delegate type to use:

var parse = s => int.Parse(s); // ERROR: Not enough type info in the lambda

The natural type of a lambda means they can be assigned to weaker types such as object or Delegate:

object parse = (string s) => int.Parse(s);   // Func<string, int>
Delegate parse = (string s) => int.Parse(s); // Func<string, int>

When it comes to expression trees, we combine "target" and "natural" types. If the target type is LambdaExpression or a non-generic Expression (the base types for all expression trees) and the lambda has a natural delegate type D, we instead produce Expression<D>:

LambdaExpression parseExpr = (string s) => int.Parse(s); // Expression<Func<string, int>>
Expression parseExpr = (string s) => int.Parse(s);       // Expression<Func<string, int>>

Natural Type for Method Groups

Method groups (i.e., method names without a parameter list) now sometimes also have a natural type. You could always convert a method group to a compatible delegate type:

Func<int> read = Console.Read;
Action<string> write = Console.Write;

Now, if a method group has only one overload, it will have a natural type:

var read = Console.Read; // Just one overload; Func<int> inferred
var write = Console.Write; // ERROR: Multiple overloads, can't choose

Return Type for Lambdas

In previous examples, the return type of a lambda expression was obvious and inferred. This is not always the case:

var choose = (bool b) => b ? 1 : "two"; // ERROR: Can't infer return type

In C# 10, you can specify an explicit return type on a lambda expression, just like on a method or local function. The return type appears before the parameters. When you specify an explicit return type, the parameters must be enclosed in parentheses so as not to confuse the compiler or other developers:

var choose = object (bool b) => b ? 1 : "two"; // Func<bool, object>

Attributes on Lambdas

Starting with C# 10, you can place attributes on lambda expressions, just like on methods and local functions. When attributes are present, the lambda's parameter list must be enclosed in parentheses:

Func<string, int> parse = [Example(1)] (s) => int.Parse(s);
var choose = [Example(2)][Example(3)] object (bool b) => b ? 1 : "two";

Just like local functions, you can apply attributes to lambdas if the attribute is valid on AttributeTargets.Method.

Lambdas are invoked differently from methods and local functions, so attributes have no effect when the lambda is called. However, attributes on lambdas are still useful for code analysis and can be discovered via reflection.

Struct Improvements

C# 10 introduces features for structs that provide better parity between structs and classes. These new features include parameterless constructors, field initializers, record structs, and with expressions.

01 Parameterless Struct Constructors and Field Initializers

Prior to C# 10, every struct had an implicit public parameterless constructor that set all fields to their default values. It was an error to create a parameterless constructor on a struct.

Starting with C# 10, you can include your own parameterless struct constructor. If you don't provide one, an implicit parameterless constructor is provided that sets all fields to their default values. The parameterless constructor you create in a struct must be public and cannot be partial:

public struct Address
{
    public Address()
    {
        City = "<unknown>";
    }
    public string City { get; init; }
}

You can initialize fields in the parameterless constructor as shown above, or you can initialize them via field or property initializers:

public struct Address
{
    public string City { get; init; } = "<unknown>";
}

Structs created by default or as part of an array allocation ignore the explicit parameterless constructor and always set struct members to their default values. For more information about parameterless constructors in structs, see Struct types.

02 Record Structs

Starting with C# 10, you can now define a record using record struct. These are similar to the record classes introduced in C# 9:

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

You can continue to use record for record classes, or use record class for clarity.

Structs already have value equality—when you compare them, it's by value. Record structs add IEquatable<T> support and the == operator. Record structs provide a custom implementation of IEquatable<T> to avoid the performance issues of reflection, and they include record features like ToString() override.

Record structs can be positional, with the primary constructor implicitly declaring public members:

public record struct Person(string FirstName, string LastName);

The parameters of the primary constructor become public auto-implemented properties of the record struct. Unlike record classes, the implicitly created properties are read/write. This makes it easier to convert tuples to named types. Changing a return type from a tuple like (string FirstName, string LastName) to a named type Person cleans up your code and ensures consistent member names. Declaring a positional record struct is easy and maintains mutable semantics.

If you declare a property or field with the same name as a primary constructor parameter, no auto-property is synthesized and yours is used.

To create an immutable record struct, add readonly to the struct (as you can with any struct) or apply readonly to individual properties. Object initializers are part of the construction phase where read-only properties can be set. This is just one way to use immutable record structs:

var person = new Person { FirstName = "Mads", LastName = "Torgersen"};

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

Learn more about record structs in this article.

03 sealed Modifier on ToString() in Record Classes

Record classes have also been improved. Starting with C# 10, the ToString() method can include the sealed modifier, which prevents the compiler from synthesizing a ToString implementation for any derived record.

Learn more about ToString() in records in this article.

04 with Expressions for Structs and Anonymous Types

C# 10 supports with expressions for all structs, including record structs, and for anonymous types:

var person2 = person with { LastName = "Kristensen" };

This returns a new instance with the new values. You can update any number of values. Values you don't set retain the same values as the initial instance.

Learn more about with in this article.

Interpolated String Improvements

When we added interpolated strings to C#, we always felt there was more we could do in terms of performance and expressiveness with that syntax.

01 Interpolated String Handlers

Today, the compiler converts interpolated strings into calls to string.Format. This results in many allocations—boxing of arguments, allocation of the argument array, and of course the resulting string itself. Furthermore, it leaves no room for flexibility in what the interpolation actually means.

In C# 10, we added a library pattern that allows APIs to "take over" the processing of interpolated string argument expressions. For example, consider StringBuilder.Append:

var sb = new StringBuilder();
sb.Append($"Hello {args[0]}, how are you?");

Previously, this would call the Append(string? value) overload with a newly allocated and computed string, appending it as one chunk to the StringBuilder. However, Append now has a new overload Append(ref StringBuilder.AppendInterpolatedStringHandler handler) that takes precedence over the string overload when an interpolated string is used as an argument.

Typically, when you see a parameter type of the form SomethingInterpolatedStringHandler, the API author has done some work behind the scenes to handle the interpolated string more appropriately for their purpose. In our Append example, the strings "Hello", args[0], and ", how are you?" are appended individually to the StringBuilder, which is more efficient while producing the same result.

Sometimes you only want to do the work of building a string under certain conditions. An example is Debug.Assert:

Debug.Assert(condition, $"{SomethingExpensiveHappensHere()}");

In most cases, the condition is true and the second argument is never used. However, every call evaluates all arguments, unnecessarily slowing execution. Debug.Assert now has an overload with a custom interpolated string builder that ensures the second argument is not even evaluated unless the condition is false.

Finally, here is an example that actually changes the behavior of string interpolation in a given call: String.Create() allows you to specify an IFormatProvider for formatting the expressions in the holes of the interpolated string argument itself:

String.Create(CultureInfo.InvariantCulture, $"The result is {result}");

You can learn more about interpolated string handlers in this article and in this tutorial on creating custom handlers.

02 Constant Interpolated Strings

If all the holes in an interpolated string are constant strings, the resulting string is now also constant. This allows you to use the string interpolation syntax in more places, such as attributes:

[Obsolete($"Call {nameof(Discard)} instead")]

Note that the holes must be filled with constant strings. Other types, such as numbers or date values, cannot be used because they are culture-sensitive and cannot be computed at compile time.

Other Improvements

C# 10 includes many smaller improvements across the language. Some simply make C# work the way you would expect.

Mixing Declarations and Variables in Deconstruction

Prior to C# 10, deconstruction required either all variables to be new or all variables to be declared beforehand. In C# 10, you can mix:

int x2;
int y2;
(x2, y2) = (0, 1);       // Works in C# 9
(var x, var y) = (0, 1); // Works in C# 9
(x2, var y3) = (0, 1);   // Works in C# 10 onwards

Learn more in the article about deconstruction.

Improved Definite Assignment

C# produces an error if you use a value that hasn't been definitely assigned. C# 10 has a better understanding of your code and produces fewer false errors. These same improvements also mean you will see fewer false errors and warnings for null references.

Learn more about C# definite assignment in the What's new in C# 10 article.

Extended Property Patterns

C# 10 adds extended property patterns to make it easier to access nested property values in patterns. For example, if we add an address to the Person record above, we can pattern match in two ways:

object obj = new Person
{
    FirstName = "Kathleen",
    LastName = "Dollard",
    Address = new Address { City = "Seattle" }
};

if (obj is Person { Address: { City: "Seattle" } })
    Console.WriteLine("Seattle");

if (obj is Person { Address.City: "Seattle" }) // Extended property pattern
    Console.WriteLine("Seattle");

The extended property pattern simplifies the code and makes it easier to read, especially when matching multiple properties.

Learn more about extended property patterns in the pattern matching article.

Caller Expression Attribute

CallerArgumentExpressionAttribute provides information about the context of a method call. Like other CompilerServices attributes, this attribute is applied to an optional parameter. In this case, a string:

void CheckExpression(bool condition,
    [CallerArgumentExpression("condition")] string? message = null )
{
    Console.WriteLine($"Condition: {message}");
}

The argument name passed to CallerArgumentExpression is the name of a different parameter. The expression passed as an argument to that parameter will be included in the string. For example:

var a = 6;
var b = true;
CheckExpression(true);
CheckExpression(b);
CheckExpression(a > 5);

// Output:
// Condition: true
// Condition: b
// Condition: a > 5

ArgumentNullException.ThrowIfNull() is a good example of how this attribute is used. It avoids having to pass in the parameter name because it is provided by default:

void MyMethod(object value)
{
    ArgumentNullException.ThrowIfNull(value);
}

Feel free to leave your suggestions or thoughts in the comments below. Thank you!

Keep Exploring

Related Reading

More Articles