Leveraging Modern C# Features in Unity for Cleaner, More Efficient Code

Unity, a powerful game engine, supports many modern C# features that can greatly enhance the efficiency, readability, and maintainability of your code. In this blog post, we'll explore these features and provide code samples to demonstrate their use in Unity. These features range from new syntax and patterns introduced in C# 8 to the latest enhancements from C# 12.

1. Switch Expressions

Switch expressions simplify and reduce the boilerplate code required by traditional switch statements.

Example:

public string GetModeTitle(GameMode mode)
{
    return mode switch
    {
        GameMode.Points => "Points mode",
        GameMode.Survive => "Survive mode",
        GameMode.TimeAttack => "Time Attack mode",
        _ => "Unsupported game mode",
    };
}

2. Property Patterns and Type Patterns

These patterns allow for more expressive and concise code when checking object properties and types.

Property Patterns Example:

public static float CalculateDamage(Enemy enemy) => enemy switch
{
    { IsVisible: true, HasArmor: true } => 1,
    { IsVisible: true, HasArmor: false } => 2,
    _ => 0
};

Type Patterns Example:

public static float GetEnemyStrength(Enemy enemy) => enemy switch
{
    Minion => 1,
    Troll => 2,
    Vampire => 3,
    null => throw new ArgumentNullException(nameof(enemy)),
    _ => throw new ArgumentException("Unknown enemy", nameof(enemy)),
};

3. Default Interface Methods

Default interface methods reduce the need for base classes by allowing interfaces to have implementations.

Example:

public interface IUpdatable
{
    void Update();

    void LateUpdate()
    {
        // Default implementation
        Debug.Log("Late Update");
    }
}

public class Player : IUpdatable
{
    public void Update()
    {
        Debug.Log("Player Update");
    }
}

4. Record Types

Record types provide a concise way to define immutable data objects, which is useful for defining game data models.

Example:

public record GameConfig(string LevelName, int MaxPlayers, float Difficulty);

var config = new GameConfig("Forest", 4, 2.5f);

5. Collection Initializers with Spread Elements

Collection expressions with spread elements make it easier to initialize collections concisely.

Example:

int[] row0 = { 1, 2, 3 };
int[] row1 = { 4, 5, 6 };
int[] row2 = { 7, 8, 9 };
int[] single = { ..row0, ..row1, ..row2 };

6. Raw String Literals

Raw string literals allow multi-line strings without needing escape sequences, useful for writing shaders or large text data.

Example:

string shaderCode = """
    Shader "Custom/MyShader" {
        Properties {
            _Color ("Main Color", Color) = (1,1,1,1)
        }
        SubShader {
            Pass {
                // Shader code here
            }
        }
    }
    """;

7. Generic Math Support

Generic math support allows the use of mathematical operators in generic types, useful for implementing mathematical operations in a type-safe manner.

Example:

public interface IAdditionOperators<TSelf, TOther, TResult>
{
    static abstract TResult operator +(TSelf left, TOther right);
}

public struct Vector2 : IAdditionOperators<Vector2, Vector2, Vector2>
{
    public float X { get; set; }
    public float Y { get; set; }

    public static Vector2 operator +(Vector2 left, Vector2 right) => new Vector2
    {
        X = left.X + right.X,
        Y = left.Y + right.Y
    };
}

8. Improved Method Group Conversion to Delegates

This feature improves performance and reduces memory overhead when using method group conversions.

Example:

public class EventPublisher
{
    public event Action OnEvent;

    public void TriggerEvent()
    {
        OnEvent?.Invoke();
    }
}

public class EventSubscriber
{
    private EventPublisher _publisher;

    public EventSubscriber(EventPublisher publisher)
    {
        _publisher = publisher;
        _publisher.OnEvent += HandleEvent;
    }

    private void HandleEvent()
    {
        // Handle the event
    }
}

9. Async Streams

Async streams allow for asynchronous iteration over data streams, useful for handling data that is received over time.

Example:

public async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    for (int i = 0; i < 10; i++)
    {
        await Task.Delay(1000);
        yield return i;
    }
}

public async void Start()
{
    await foreach (var number in GenerateNumbersAsync())
    {
        Debug.Log(number);
    }
}

10. Nullable Reference Types

Nullable reference types help in reducing null reference exceptions by explicitly defining whether a reference can be null or not.

Example:

#nullable enable
public class Player
{
    public string Name { get; set; }
    public string? Nickname { get; set; } // Can be null
}

11. Ranges and Indices

Ranges and indices provide a succinct syntax for slicing arrays and collections.

Example:

public void Start()
{
    int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    int[] slice = numbers[2..5]; // { 2, 3, 4 }
    Debug.Log(string.Join(", ", slice));

    int last = numbers[^1]; // 9
    Debug.Log(last);
}

12. Using Declarations

Using declarations simplify resource management by ensuring resources are disposed of when they go out of scope.

Example:

public void ReadFile(string path)
{
    using var reader = new StreamReader(path);
    string content = reader.ReadToEnd();
    Debug.Log(content);
} // reader is disposed here

13. ReadOnly Members

Read-only members help enforce immutability for structs and classes.

Example:

public struct Vector
{
    public float X { get; }
    public float Y { get; }

    public Vector(float x, float y)
    {
        X = x;
        Y = y;
    }

    public readonly float Magnitude => MathF.Sqrt(X * X + Y * Y);
}

14. Enhanced Stackalloc

Enhanced stack allocation provides more efficient memory management for performance-critical code.

Example:

public unsafe void ProcessData()
{
    Span<int> numbers = stackalloc int[100];
    for (int i = 0; i < numbers.Length; i++)
    {
        numbers[i] = i;
    }
    // Process numbers...
}

15. In Parameters

In parameters allow passing parameters by reference without modifying the argument, improving performance for large structures.

Example:

public void Calculate(in Vector vector)
{
    float magnitude = vector.Magnitude;
    Debug.Log(magnitude);
}

These modern C# features provide powerful tools for writing cleaner, more efficient, and more maintainable code in Unity. By leveraging these features, you can improve your development workflow and create more robust and scalable Unity projects. For more detailed information and further examples, refer to the Unity documentation and the Microsoft C# language reference.