New C# High Efficiency Programming Guide

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  init 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, asand 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.

Guess you like

Origin blog.csdn.net/sD7O95O/article/details/108819463