Preface
From version 7 to version 9 today, C# has added a lot of features, many of which improve performance, increase program robustness, code conciseness, and readability improvements. Here I sort out some personal recommendations when using the new version of C# The wording of may not be suitable for everyone, but I still hope it will help you.
Note: This guide is applicable to .NET 5 or above.
Use ref struct to achieve 0 GC
C# 7 began to introduce a so-called ref struct
structure, which is essentially struct
that the structure is stored in stack memory. But the struct
difference is that the structure does not allow any interfaces to be implemented, and the compiler guarantees that the structure will never be boxed, so it will not bring any pressure to the GC. On the other hand, there will be mandatory restrictions that cannot escape the stack during use.
Span<T>
It is ref struct
the product of use that successfully encapsulates a safe and high-performance memory access operation, and can replace pointers in most cases without losing any performance.
Copyref struct MyStruct
{
public int Value { get; set; }
}
class RefStructGuide
{
static void Test()
{
MyStruct x = new MyStruct();
x.Value = 100;
Foo(x); // ok
Bar(x); // error, x cannot be boxed
}
static void Foo(MyStruct x) { }
static void Bar(object x) { }
}
Use the in keyword to pass an unmodifiable reference
When the parameters are ref
passed, although the passed is a reference, it cannot ensure that the reference value is not modified by the other party. At this time, you only need to ref
change in
it to ensure safety:
CopySomeBigReadonlyStruct x = ...;
Foo(x);
void Foo(in SomeBigReadonlyStruct v)
{
v = ...; // error
}
The readonly struct
benefits are very obvious when using large ones .
Use stackalloc to allocate contiguous memory on the stack
For some performance-sensitive situations that need to use a small amount of contiguous memory, you don't need to use an array. Instead, you can stackalloc
allocate memory directly on the stack and use it Span<T>
for safe access. Similarly, this can achieve zero GC pressure.
stackalloc
Any value type structure is allowed, but note that it Span<T>
is currently not supported ref struct
as a generic parameter, so ref struct
you need to use pointers directly when using it.
Copyref struct MyStruct
{
public int Value { get; set; }
}
class AllocGuide
{
static unsafe void RefStructAlloc()
{
MyStruct* x = stackalloc MyStruct[10];
for (int i = 0; i < 10; i++)
{
*(x + i) = new MyStruct { Value = i };
}
}
static void StructAlloc()
{
Span<int> x = stackalloc int[10];
for (int i = 0; i < x.Length; i++)
{
x[i] = i;
}
}
}
Use Span to manipulate contiguous memory
C# 7 began to be introduced Span<T>
, it encapsulates a safe and high-performance memory access operation method, which can be used to replace pointer operations in most cases.
Copystatic void SpanTest()
{
Span<int> x = stackalloc int[10];
for (int i = 0; i < x.Length; i++)
{
x[i] = i;
}
ReadOnlySpan<char> str = "12345".AsSpan();
for (int i = 0; i < str.Length; i++)
{
Console.WriteLine(str[i]);
}
}
Use SkipLocalsInit for frequently called functions when performance is sensitive
In order to ensure the safety of the code, C# initializes all local variables when they are declared, whether necessary or not. Under normal circumstances, this does not have much impact on performance, but if your function manipulates a lot of memory allocated on the stack and the function is called frequently, the side effects of this consumption will be magnified and become non-negligible loss.
So you can use SkipLocalsInit
this feature to disable the automatic initialization of local variables.
Copy[SkipLocalsInit]
unsafe static void Main()
{
Guid g;
Console.WriteLine(*&g);
}
The above code will output unexpected results because g
it is not initialized to 0. In addition, accessing uninitialized variables requires unsafe
accessing using pointers in the context.
Use function pointers instead of Marshal for interoperability
C# 9 brings the function pointer function, which supports managed and unmanaged functions. When performing native interop, using function pointers can significantly improve performance.
For example, you have the following C++ code:
Copy#define UNICODE
#define WIN32
#include <cstring>
extern "C" __declspec(dllexport) char* __cdecl InvokeFun(char* (*foo)(int)) {
return foo(5);
}
And you wrote the following C# code for interoperability:
Copy[DllImport("./Test.dll")]
static extern string InvokeFun(delegate* unmanaged[Cdecl]<int, IntPtr> fun);
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static IntPtr Foo(int x)
{
var str = Enumerable.Repeat("x", x).Aggregate((a, b) => $"{a}{b}");
return Marshal.StringToHGlobalAnsi(str);
}
static void Main(string[] args)
{
var callback = (delegate* unmanaged[Cdecl]<int, nint>)(delegate*<int, nint>)&Foo;
Console.WriteLine(InvokeFun(callback));
}
In the above code, first C# passes its own Foo
method as a function pointer to a C++ InvokeFun
function, and then C++ calls the function with parameter 5 and returns its return value to the caller of C#.
Note that the above code also uses UnmanagedCallersOnly
this feature, so that you can tell the compiler that this method will only be called from unmanaged code, so the compiler can do some additional optimizations.
IL instructions generated using function pointers are very efficient:
Copyldftn native int Test.Program::Foo(int32)
stloc.0
ldloc.0
call string Test.Program::InvokeFun(method native int *(int32))
In addition to unmanaged, managed functions can also use function pointers:
Copystatic void Foo(int v) { }
unsafe static void Main(string[] args)
{
delegate* managed<int, void> fun = &Foo;
fun(4);
}
The generated code is more efficient than the original Delegate:
Copyldftn void Test.Program::Foo(int32)
stloc.0
ldc.i4.4
ldloc.0
calli void(int32)
Use pattern matching
With if-else
, as
and forced type conversion, why use pattern matching? There are three reasons: performance, robustness and readability.
Why is performance also a reason? Because the C# compiler will compile the optimal matching path according to your pattern.
Consider the following code (code 1):
Copyint Match(int v)
{
if (v > 3)
{
return 5;
}
if (v < 3)
{
if (v > 1)
{
return 6;
}
if (v > -5)
{
return 7;
}
else
{
return 8;
}
}
return 9;
}
If you use pattern matching instead, the matching switch
expression becomes (code 2):
Copyint Match(int v)
{
return v switch
{
> 3 => 5,
< 3 and > 1 => 6,
< 3 and > -5 => 7,
< 3 => 8,
_ => 9
};
}
The above code will be compiled by the compiler to:
Copyint Match(int v)
{
if (v > 1)
{
if (v <= 3)
{
if (v < 3)
{
return 6;
}
return 9;
}
return 5;
}
if (v > -5)
{
return 7;
}
return 8;
}
Let's calculate the average number of comparisons:
Code | 5 | 6 | 7 | 8 | 9 | total | average |
---|---|---|---|---|---|---|---|
Code 1 | 1 | 3 | 4 | 4 | 2 | 14 | 2.8 |
Code 2 | 2 | 3 | 2 | 2 | 3 | 12 | 2.4 |
It can be seen that when using pattern matching, the compiler chooses a better comparison scheme. You do not need to consider how to organize judgment statements when writing, which reduces mental burden, and the readability and conciseness of Code 2 is obviously better than Code 1. It is clear at a glance which conditional branches are.
Even in situations similar to the following:
Copyint Match(int v)
{
return v switch
{
1 => 5,
2 => 6,
3 => 7,
4 => 8,
_ => 9
};
}
The compiler will directly compile the code from the conditional judgment statement into a switch
statement:
Copyint Match(int v)
{
switch (v)
{
case 1:
return 5;
case 2:
return 6;
case 3:
return 7;
case 4:
return 8;
default:
return 9;
}
}
In this way, all judgments do not need to be compared (because you switch
can jump directly according to HashCode).
The compiler chooses the best solution for you very intelligently.
So where do you talk about robustness? Suppose you missed a branch:
Copyint v = 5;
var x = v switch
{
> 3 => 1,
< 3 => 2
};
If you compile at this time, the compiler will warn you that you missed a v
possible 3, which helps reduce the possibility of program errors.
Last point, readability.
Suppose you now have something like this:
Copyabstract class Entry { }
class UserEntry : Entry
{
public int UserId { get; set; }
}
class DataEntry : Entry
{
public int DataId { get; set; }
}
class EventEntry : Entry
{
public int EventId { get; set; }
// 如果 CanRead 为 false 则查询的时候直接返回空字符串
public bool CanRead { get; set; }
}
Now there Entry
is a function that receives parameters of type . This function Entry
corresponds to different types of queries to the database Content
, so you only need to write:
Copystring QueryMessage(Entry entry)
{
return entry switch
{
UserEntry u => dbContext1.User.FirstOrDefault(i => i.Id == u.UserId).Content,
DataEntry d => dbContext1.Data.FirstOrDefault(i => i.Id == d.DataId).Content,
EventEntry { EventId: var eventId, CanRead: true } => dbContext1.Event.FirstOrDefault(i => i.Id == eventId).Content,
_ => throw new InvalidArgumentException("无效的参数")
};
}
Furthermore, if it is Entry.Id
distributed in databases 1 and 2, if it is not found in database 1, you need to go to database 2 to query, and if 2 is not found, an empty string will be returned. Because C#'s pattern matching supports recursive mode, So just write:
Copystring QueryMessage(Entry entry)
{
return entry switch
{
UserEntry u => dbContext1.User.FirstOrDefault(i => i.Id == u.UserId) switch
{
null => dbContext2.User.FirstOrDefault(i => i.Id == u.UserId)?.Content ?? "",
var found => found.Content
},
DataEntry d => dbContext1.Data.FirstOrDefault(i => i.Id == d.DataId) switch
{
null => dbContext2.Data.FirstOrDefault(i => i.Id == u.DataId)?.Content ?? "",
var found => found.Content
},
EventEntry { EventId: var eventId, CanRead: true } => dbContext1.Event.FirstOrDefault(i => i.Id == eventId) switch
{
null => dbContext2.Event.FirstOrDefault(i => i.Id == eventId)?.Content ?? "",
var found => found.Content
},
EventEntry { CanRead: false } => "",
_ => throw new InvalidArgumentException("无效的参数")
};
}
It’s all done, the code is very concise, and the flow of the data can be seen clearly at a glance. Even if someone who has not touched this part of the code looks at the pattern matching process, they can immediately grasp the situation of each branch at a glance, without the need if-else
Sort out what this code does in a bunch of it.
Use record types and immutable data
record
As a new tool of C# 9, with init
only initialized properties, it brings us efficient data interaction capabilities and immutability.
Elimination of variability means no side effects. A side-effect-free function does not need to worry about data synchronization and mutual exclusion, so it is very useful in lock-free parallel programming.
Copyrecord Point(int X, int Y);
A simple sentence is equivalent to writing the following code, which helps us solve various problems such as ToString()
formatted output, value-based GetHashCode()
and equality judgments:
Copyinternal class Point : IEquatable<Point>
{
private readonly int x;
private readonly int y;
protected virtual Type EqualityContract => typeof(Point);
public int X
{
get => x;
set => x = value;
}
public int Y
{
get => y;
set => y = value;
}
public Point(int X, int Y)
{
x = X;
y = Y;
}
public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("Point");
stringBuilder.Append(" { ");
if (PrintMembers(stringBuilder))
{
stringBuilder.Append(" ");
}
stringBuilder.Append("}");
return stringBuilder.ToString();
}
protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append("X");
builder.Append(" = ");
builder.Append(X.ToString());
builder.Append(", ");
builder.Append("Y");
builder.Append(" = ");
builder.Append(Y.ToString());
return true;
}
public static bool operator !=(Point r1, Point r2)
{
return !(r1 == r2);
}
public static bool operator ==(Point r1, Point r2)
{
if ((object)r1 != r2)
{
if ((object)r1 != null)
{
return r1.Equals(r2);
}
return false;
}
return true;
}
public override int GetHashCode()
{
return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<int>.Default.GetHashCode(x)) * -1521134295 + EqualityComparer<int>.Default.GetHashCode(y);
}
public override bool Equals(object obj)
{
return Equals(obj as Point);
}
public virtual bool Equals(Point other)
{
if ((object)other != null && EqualityContract == other.EqualityContract && EqualityComparer<int>.Default.Equals(x, other.x))
{
return EqualityComparer<int>.Default.Equals(y, other.y);
}
return false;
}
public virtual Point Clone()
{
return new Point(this);
}
protected Point(Point original)
{
x = original.x;
y = original.y;
}
public void Deconstruct(out int X, out int Y)
{
X = this.X;
Y = this.Y;
}
}
Notice x
and y
all readonly
, so once the instance variable is not created, if you want to change can with
create a copy, so in this way completely eliminates any side effects.
Copyvar p1 = new Point(1, 2);
var p2 = p1 with { Y = 3 }; // (1, 3)
Of course, you can also use init
attributes yourself to indicate that this attribute can only be assigned during initialization:
Copyclass Point
{
public int X { get; init; }
public int Y { get; init; }
}
As a result, once Point
created, the X
and Y
the value will not be changed, it can safely be used in parallel programming model, without the need to lock.
Copyvar p1 = new Point { X = 1, Y = 2 };
p1.Y = 3; // error
var p2 = p1 with { Y = 3 }; //ok
Use readonly type
The above mentioned the importance of immutability, of course, struct
it can also be read-only:
Copyreadonly struct Foo
{
public int X { get; set; } // error
}
The above code will report an error because it violates the X
read-only constraint.
If changed to:
Copyreadonly struct Foo
{
public int X { get; }
}
or
Copyreadonly struct Foo
{
public int X { get; init; }
}
Then there is no problem.
Span<T>
It is one in itself readonly ref struct
. By doing so, it ensures Span<T>
that the contents will not be accidentally modified, ensuring immutability and safety.
Use local functions instead of lambdas to create temporary delegates
When using Expression<Func<>>
the API as a parameter, it is very correct to use lambda expressions, because the compiler will compile the lambda expressions we write into Expression Trees instead of intuitive function delegates.
In just simply Func<>
, Action<>
when using a lambda expression is probably not a good decision, because it will certainly introduce a new closure, resulting in additional overhead and GC pressure. Starting from C# 8, we can replace lambdas with local functions:
Copyint SomeMethod(Func<int, int> fun)
{
if (fun(3) > 3) return 3;
else return fun(5);
}
void Caller()
{
int Foo(int v) => v + 1;
var result = SomeMethod(Foo);
Console.WriteLine(result);
}
The above code will not cause an unnecessary closure overhead.
Use ValueTask instead of Task
When we encounter Task<T>
it, in most cases, we just need to simply perform await
it, and we don’t need to save it for later await
. So Task<T>
many of the functions provided are not used, but under high concurrency, due to repeated Allocation Task
causes GC pressure to increase.
In this case, we can use ValueTask<T>
instead Task<T>
:
CopyValueTask<int> Foo()
{
return ValueTask.FromResult(1);
}
async ValueTask Caller()
{
await Foo();
}
Because it ValueTask<T>
is a value type structure, the object itself will not allocate memory on the heap, so GC pressure can be reduced.
Implement destructuring functions instead of creating tuples
If we want to extract the data in a type, we can choose to return a tuple containing the data we need:
Copyclass Foo
{
private int x;
private int y;
public Foo(int x, int y)
{
this.x = x;
this.y = y;
}
public (int, int) Deconstruct()
{
return (x, y);
}
}
class Program
{
static void Bar(Foo v)
{
var (x, y) = v.Deconstruct();
Console.WriteLine($"X = {x}, Y = {y}");
}
}
The above code will cause an ValueTuple<int, int>
overhead. If we change the code to implement the deconstruction method:
Copyclass Foo
{
private int x;
private int y;
public Foo(int x, int y)
{
this.x = x;
this.y = y;
}
public void Deconstruct(out int x, out int y)
{
x = this.x;
y = this.y;
}
}
class Program
{
static void Bar(Foo v)
{
var (x, y) = v;
Console.WriteLine($"X = {x}, Y = {y}");
}
}
Not only is Deconstruct()
the call saved , but there is no additional overhead. You can see that implementing the Deconstruct function does not require your type to implement any interface, which fundamentally eliminates the possibility of boxing. This is a zero-overhead abstraction. In addition, the destructuring function can also be used for pattern matching. You can use the destructuring function like a tuple (the following code means that when it x
is 3, take it y
, otherwise take it x + y
):
Copyvoid Bar(Foo v)
{
var result = v switch
{
Foo (3, var y) => y,
Foo (var x, var y) => x + y,
_ => 0
};
Console.WriteLine(result);
}
Null security
After enabling null security in the project properties file csproj, you can enable null security static analysis for the entire project code:
Copy<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
In this way, you can check all potential NRE problems during compilation. For example, the following code:
Copyvar list = new List<Entry>();
var value = list.FirstOrDefault(i => i.Id == 3).Value;
Console.WriteLine(value);
list.FirstOrDefault()
It may return null
, so the compiler will give a warning after enabling null security, which helps to avoid unnecessary NRE exceptions.
In addition, after enabling null security, for nullable reference types, you can also add one after the type ?
to indicate that it can be null
:
Copystring? x = null;
to sum up
Using the new features of C# at the right time can not only improve development efficiency, but also take into account the improvement of code quality and operational efficiency.
But avoid abuse. The introduction of new features is undoubtedly a great help for us to write high-quality code, but if used in a timely manner, it may bring adverse effects.
I hope this article can bring some help to developers when using the new version of C#, thank you for reading.