C# 14 is almost here, so it's time for our annual feature overview. This year brought fewer changes than the last. Some might consider them minor, but is it really so? Let's take a closer look.
Yes, we can now omit writing a field for a property. Some might say that we could already write properties like this:
public string Name { get; set; }
This is an auto-implemented property, which creates a hidden field. However, the problem is that it can only be used when no additional logic is required. If we need such logic, we have to create a field just to store the data so we could process it in the property setter.
For example, when we need to trim whitespaces from an email address before saving it, we would typically write something like this:
public class User
{
private string _oldEmail;
public string OldEmail
{
get => _oldEmail;
set => _oldEmail = value.Trim();
}
}
We're used to seeing this. But now, we can manage without explicitly declaring the field:
public class User
{
public string Email
{
get; // The `field` is used here implicitly
set => field = value.Trim();
}
}
If we need to perform additional actions on the value, we can now use the field
keyword and avoid writing the field ourselves. It took a few years, but we finally have this feature! This new C# version once again enhances readability and reduces the number of code lines.
Remember when using modifiers like scoped
, ref
, in
, out
, ref readonly
inside a lambda required explicitly specifying the parameter type? Look at this code snippet as an example:
delegate bool ValidatorHandler(object value, out string errorMessage);
public void Validate(object objectValue)
{
ValidatorHandler Validator = (object value, out string error) =>
{
// ....
};
// ....
}
Now, using modifiers without the parameter type is allowed:
ValidatorHandler Validator = (value, out error) =>
{
// ....
};
This innovation lets us focus on writing code rather than describing template details. It seems this feature will most often be used with out-parameters.
This optimization feature allows overloading not just unary or binary operators, but also compound operators like +=
, *=
, and others. Since these operators are very similar in implementation, we'll use +=
as an example, but the conclusions apply to similar operators as well.
Note that in C#13, when using the "+=" operator, the overloaded "+" operator is called first, followed by the assignment.
When working with value types, overloading the +
operator creates additional copies of both operands and creates a new instance as a result. This behavior is expected, but it can lead to unjustified performance costs from copying and processing values, especially when working with large types like mathematical vectors, tensors, and similar structures.
We could write a method that works similarly to the +=
operator, minimizing copying and enabling modification of the first operand:
public struct Vector3
{
public double X, Y, Z;
public Vector3(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
public void Add(Vector3 vector)
{
X += vector.X;
Y += vector.Y;
Z += vector.Z;
}
}
Although the Add
method avoids most drawbacks, it looks less natural than the +=
operator.
Overloading the +=
operator looks like this:
public void operator +=(Vector3 right)
{
X += right.X;
Y += right.Y;
Z += right.Z;
}
The result remains the same while code readability is enhanced. Moreover, it prevents situations where a developer working with large types suddenly discovers a sharp performance drop in their code.
A few words about reference types. Suppose we don't overload the +=
operator for a reference type. This way, when using it, the +
operator will execute in the same way, followed by the assignment. In the example above, the +
operator is overloaded in such a way, that a new object is not created—we perform the operation on the object. So, the +
operation doesn't create a new object. However, a new object might be created in the +
implementation, which is undesirable in the context of the +=
operator.
C# 13 allowed the use of partial
for properties and indexers. C# 14 extends partial
to split constructors and events.
As with other uses of partial
, this feature will be especially useful for developers of libraries and code generators. For example, partial
events would be useful for weak event libraries, while partial constructors would benefit platforms that generate interop code, such as Xamarin.
The only difference is that partial constructors and partial events must have an implementation, whereas methods can remain declared only.
This C# version continues to enhance readability. Another added feature enables us to check a variable for null
not only on the right side of an expression but also on the left side as well.
Important! Note that increment and decrement operators can't be used in this case.
Previously, for assignments with potential null
dereferencing, we needed a classic null
check in the form of the if
statement.
public class User
{
public DateTime LastActive { get; set; }
public bool LoggedIn { get; set; }
// ....
public void UpdateLastActivity(User user)
{
if (user is not null)
user.LastActive = DateTime.UtcNow;
// ....
}
}
In C# 14, this can be shortened to just one line:
public void UpdateLastActivity(User user)
{
user?.LastActive = DateTime.UtcNow;
// ....
}
Earlier, the null-conditional operator could only be used for checks, not for performing assignments.
if (user?.LoggedIn == true) // OK
{
user?.LoggedIn = false; // Error
}
What's the effect of this feature? The behavior remains the same: the right side of the expression won't be evaluated if the check fails, but the code looks much more compact.
Now we can write not only extension methods but also properties. A new keyword, extension
, was added to define extension blocks.
How could we simulate this before? The old approach was clunky, needed workarounds, and required more code. So, let's move on to the implementation example, as the code speaks louder than words anyway:
public static class ExtensionMembers
{
public static EnumerableWrapper<TSource> AsExtended<TSource>
(this IEnumerable<TSource> source) =>
new(source);
}
public class EnumerableWrapper<T> : IEnumerable<T>
{
private readonly IEnumerable<T> _source;
public EnumerableWrapper(IEnumerable<T> source)
{
_source = source;
}
public IEnumerator<T> GetEnumerator() => _source.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _source.GetEnumerator();
public bool IsEmpty => !_source.Any();
}
public class TestClass
{
public void OldExtensions()
{
var enumerable = new List<int>();
bool isEmpty = enumerable.AsExtended().IsEmpty;
}
}
That is, to get the IsEmpty
property, IEnumerable
had to become a wrapper. Still looks rather clunky, right? If you think otherwise, just look at how we can write this now:
public static class ExtensionMembers
{
extension<TSource>(IEnumerable<TSource> source)
{
public bool IsEmpty => !source.Any();
}
}
public class TestClass
{
public void NewExtensions()
{
var enumerable = new List<int>();
bool isEmpty = enumerable.IsEmpty;
}
}
As a result, the code is simpler and more pleasant to read. Also such extensions look much more natural compared to the old ones. Note that in this case, IsEmpty
is called as an instance member of IEnumerable<TSource>
, but we can create its static members by declaring the extension block as follows:
extension<TSource>(IEnumerable<TSource>)
{
// ....
}
However, new extensions can be used in more interesting ways. For example, to simplify an age-old string check for an empty string or create a getter to get a string with the first letter capitalized:
public static class StringExtensions
{
extension(string str)
{
public bool IsNullOrEmpty => string.IsNullOrEmpty(str);
public bool IsNullOrWhiteSpace => string.IsNullOrWhiteSpace(str);
public string ToTitleCase =>
System.Globalization.CultureInfo
.CurrentCulture.TextInfo
.ToTitleCase(str.ToLower());
}
}
As a result, we can now extend the types more naturally with not just methods but also with properties, without creating wrapper classes.
This change helps create more optimized code with less effort. C# has learned to perform more implicit conversions, making it more pleasant to use Span
. First, let's find out why Span
is worth considering as a way to improve the code.
Note: Using Span
is critical in high-performance scenarios where every millisecond matters.
Span
is a type that allows efficient memory management. It's like creating a "window" through which we interact with memory. For example, if we want to perform an operation on a string, we can simply pass it to a method. But what if we only need a part of that string? The Substring
method would have to create a new string, incurring unnecessary memory overhead.
We can avoid this by using Span
. For example, to get a substring without Span
, we would write:
string str = "test string";
string testString = str.Substring(startIndex: 0, length: 4);
This allocates memory for a new substring, which is inefficient as it requires time and resources. Here is the version with Span
:
ReadOnlySpan<char> testSpan = str.AsSpan().Slice(start: 0, length: 4);
Here, no memory is allocated for the substring because we access the memory containing the original string directly, which is faster and more efficient.
We can think of Span
as the behavior of any person given the task of finding, say, a chapter in a book. None of us would rewrite it. We'd just find the beginning and end of the chapter.
Additionally, Span
, like arrays, prevents accessing beyond the bounds of the allocated memory "window," which could happen with unsafe memory access. It checks the bounds and throws an error if the index is out of range.
Now, with a general understanding of why we need this structure, let's examine the change itself. A quick reminder: implicit conversions to Span
have already been available, but the list is now expanded.
Suppose we have a method to process ReadOnlySpan
:
public static void ProcessSpan<T>(ReadOnlySpan<T> span)
{
// ....
}
And we try to call it, passing different data:
var intArray = new int[10];
string str = "test";
Span<char> charSpan = new();
ReadOnlySpan<char> charReadOnlySpan = new();
ProcessSpan(str); // Error
ProcessSpan(intArray); // Error
ProcessSpan(charSpan); // Error
ProcessSpan(charReadOnlySpan); // OK
In this case, no implicit conversions occurred. To make it work, we had to do something like:
ProcessSpan(str.AsSpan());
ProcessSpan<int>(intArray.AsSpan());
ProcessSpan<char>(charSpan);
ProcessSpan(charReadOnlySpan);
But with new implicit conversions, things become much simpler. The only remaining caveat is that for strings, we still need to specify the generic char
parameter.
ProcessSpan<char>(str);
ProcessSpan(intArray);
ProcessSpan(charSpan);
ProcessSpan(charReadOnlySpan);
Now, writing more performant code and spending less time on refactoring becomes much easier.
Previously, we could only get a type name by calling nameof
with a specified parameter:
Console.WriteLine(nameof(List<int>));
Now, generic implementations are available:
Console.WriteLine(nameof(List<>));
Yes, the output of both statements is the same, but the point is that this way we don't mislead anyone and clearly express the intent to get the name of the open generic type. Therefore, refactoring and understanding code will be easier when we don't have to figure out why int
showed up in the parameters.
New C#14 features bring even more convenience and expand developer abilities. Although, as usual, they include their share of "syntactic sugar." Looking ahead, we can assume that the new extensions, the field
keyword, and null-conditional assignment will be in the highest demand. What do you think of these changes? Please share your thoughts in the comments.
You are welcome to review the C#14 documentation via this link. Don't forget to check out our overviews of previous versions, here is the list of them:
0