Exploring C# 12: New Features and Enhancements

Table of Contents

C# 12 is the latest version of the C# programming language, which is part of the .NET ecosystem—a platform for building various types of applications, such as web, mobile, desktop, and cloud-based applications. C# has been a popular language among developers due to its versatility, performance, and ease of use. With each new version, Microsoft introduces enhancements that help developers write better and more efficient code. If you’re looking to hire C Sharp developer talent, understanding the latest features of C# 12 is crucial.

C# 12 brings a host of new features and improvements designed to make development easier and more productive. These features are particularly important for C# developers because they address common challenges and introduce new ways to handle tasks more efficiently. By adopting these new features, developers can improve the quality and maintainability of their code, streamline their workflows, and ultimately deliver better software faster.

Now that we have an understanding of the significance of C# 12 and the importance of its new features, let’s explore each of the major enhancements in detail. We’ll explore each of the major enhancements in detail, starting with Collection Expressions and moving through to Experimental Features. These sections will provide practical examples and explanations to help you fully leverage the power of C# 12 in your development projects.

1. Collection Expressions

Collection expressions in C# 12 provide a streamlined way to create and manipulate collections like arrays, lists, and spans. They allow for more concise and readable code by using a new syntax that reduces the verbosity traditionally associated with collection initialization. This feature simplifies the initialization of collections, enhances code readability, and makes the maintenance of large codebases easier.

Examples and Use Cases

A. Creating Arrays, Lists, and Spans

With collection expressions, you can create arrays, lists, and spans directly using a more compact syntax. Here are some examples:

Creating an Array:

int[] numbers = [1, 2, 3, 4, 5]; 

Creating a List:

List names = ["Alice", "Bob", "Charlie"]

Creating a Span:

Span letters = ['a', 'b', 'c', 'd'];

Creating a Jagged Array:

int[][] jaggedArray = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];

B. Using the Spread Operator (..)

The spread operator (..) in collection expressions allows you to include elements from another collection seamlessly. This operator can be particularly useful when combining multiple collections.


        int[] part1 = [1, 2, 3];
        int[] part2 = [4, 5, 6];
        int[] combined = [.. part1, .. part2];

        foreach (var number in combined)
        {
        Console.Write($"{number} ");
        }
        // Output: 1 2 3 4 5 6
    

Benefits in Terms of Code Readability and Maintenance

Collection expressions make the code more concise and readable, reducing the need for boilerplate code. This simplicity helps in maintaining large codebases by making initialization patterns straightforward and easy to understand.

A. Without Collection Expressions:


        int[] numbers = new int[] { 1, 2, 3, 4, 5 };
        List names = new List { "Alice", "Bob", "Charlie" };
        Span letters = new Span(new char[] { 'a', 'b', 'c', 'd' });
        int[][] jaggedArray = new int[][] {
            new int[] { 1, 2, 3 },
            new int[] { 4, 5, 6 },
            new int[] { 7, 8, 9 }
        };
    

B. With Collection Expressions:


        int[] numbers = [1, 2, 3, 4, 5];
        List names = ["Alice", "Bob", "Charlie"];
        Span letters = ['a', 'b', 'c', 'd'];
        int[][] jaggedArray = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
    

Comparison with Previous Methods

Previously, initializing collections required more verbose syntax, especially when dealing with nested collections or spans. Collection expressions simplify these tasks, making the code less cluttered and more intuitive.

A. Example of Combining Collections (Before C# 12):


        int[] part1 = new int[] { 1, 2, 3 };
        int[] part2 = new int[] { 4, 5, 6 };
        int[] combined = new int[part1.Length + part2.Length];
        Array.Copy(part1, combined, part1.Length);
        Array.Copy(part2, 0, combined, part1.Length, part2.Length);

        foreach (var number in combined)
        {
            Console.Write($"{number} ");
        }
        // Output: 1 2 3 4 5 6
    

B. Using Collection Expressions (C# 12):


        int[] part1 = [1, 2, 3];
        int[] part2 = [4, 5, 6];
        int[] combined = [.. part1, .. part2];

        foreach (var number in combined)
        {
            Console.Write($"{number} ");
        }
        // Output: 1 2 3 4 5 6
    

This new approach not only reduces the amount of code but also enhances clarity, making it easier for C# developers to understand and maintain their code.

2. Primary Constructors for Classes and Structs

Primary constructors in C# 12 extend the concept of primary constructors, previously available only for records, to all classes and structs. A primary constructor allows you to define constructor parameters directly in the class or struct declaration, reducing boilerplate code and improving readability. The primary constructor parameters are in scope throughout the body of the type, which simplifies initialization and property assignment.

Syntax and Examples

A. Syntax for Defining Primary Constructors

The syntax for primary constructors involves placing the constructor parameters directly after the class or struct name in parentheses. Here’s the basic syntax:


        public class ClassName(Type1 param1, Type2 param2)
        {
            // Class body where param1 and param2 are accessible
        }
    

B. Practical Examples Demonstrating Initialization of Objects

Example 1: Class with Primary Constructor


        public class Student(int id, string name)
        {
            public int Id { get; } = id;
            public string Name { get; set; } = name.Trim();
        
            public void DisplayInfo()
            {
                Console.WriteLine($"ID: {Id}, Name: {Name}");
            }
        }
        
        // Usage
        var student = new Student(1, "Alice");
        student.DisplayInfo(); // Output: ID: 1, Name: Alice        
    

Example 2: Struct with Primary Constructor


        public struct Point(int x, int y)
        {
            public int X { get; } = x;
            public int Y { get; } = y;

            public double DistanceFromOrigin()
            {
                return Math.Sqrt(X * X + Y * Y);
            }
        }

        // Usage
        var point = new Point(3, 4);
        Console.WriteLine($"Distance from origin: {point.DistanceFromOrigin()}"); // Output: Distance from origin: 5
    

In these examples, the primary constructors simplify the process of initializing properties directly from the parameters, reducing the need for additional code inside the constructor body.

Advantages Over Traditional Constructors

A. Reduction in Boilerplate Code

Traditionally, defining a class or struct with properties required explicit constructor definitions and property assignments, leading to verbose and repetitive code. Primary constructors eliminate this redundancy by allowing you to define and initialize properties in a single step.

B. Improved Readability

With primary constructors, the intent and initialization logic of a class or struct become immediately clear. This improves code readability and maintainability, as there are fewer lines of code and less room for errors.

C. Traditional Constructor Example:


        public class Student
        {
            public int Id { get; }
            public string Name { get; set; }

            public Student(int id, string name)
            {
                Id = id;
                Name = name.Trim();
            }

            public void DisplayInfo()
            {
                Console.WriteLine($"ID: {Id}, Name: {Name}");
            }
        }

        // Usage
        var student = new Student(1, "Alice");
        student.DisplayInfo(); // Output: ID: 1, Name: Alice
    

D. Primary Constructor Example:


        public class Student(int id, string name)
        {
            public int Id { get; } = id;
            public string Name { get; set; } = name.Trim();

            public void DisplayInfo()
            {
                Console.WriteLine($"ID: {Id}, Name: {Name}");
            }
        }

        // Usage
        var student = new Student(1, "Alice");
        student.DisplayInfo(); // Output: ID: 1, Name: Alice

    

In the primary constructor example, the class definition is more concise and easier to understand. By eliminating the need for an explicit constructor body, the code is cleaner and more focused on the actual functionality.

3. Default Parameters for Lambda Expressions

Lambda expressions are a feature in C# that allow you to write anonymous methods, which are methods without a name, in a concise way. They are often used to define small pieces of functionality that can be passed as arguments to other methods. For instance, they are commonly used with LINQ (Language Integrated Query) to write queries against collections in a clear and readable manner.

A simple lambda expression example:


        Func<int, int=""> square = x => x * x;
        Console.WriteLine(square(5)); // Output: 25
    

Enhancements in C# 12

In C# 12, you can now define default values for parameters in lambda expressions. This means you can specify a default value that will be used if no argument is provided when the lambda is called. This enhancement makes lambdas more flexible and reduces the need for overloads or additional logic to handle optional parameters.

Examples and Use Cases

A. Syntax for Defining Default Parameters

The syntax for defining default parameters in lambda expressions is similar to defining default parameters for regular methods. You simply specify the default value in the parameter list.


        Func<int, int,="" int=""> add = (a, b = 5) => a + b;
        Console.WriteLine(add(3));    // Output: 8 (b uses the default value 5)
        Console.WriteLine(add(3, 2)); // Output: 5 (b is explicitly set to 2)
    

B. Practical Examples Showcasing Default Parameter Usage

Example 1: Default Increment in a Lambda


        Func<int, int=""> increment = (x, incrementBy = 1) => x + incrementBy;
        Console.WriteLine(increment(5));    // Output: 6 (incrementBy uses the default value 1)
        Console.WriteLine(increment(5, 3)); // Output: 8 (incrementBy is explicitly set to 3)
    

Example 2: Lambda with Multiple Default Parameters


        Func<int, int,="" int=""> multiply = (a, b = 2, c = 3) => a * b * c;
        Console.WriteLine(multiply(4));       // Output: 24 (b and c use default values 2 and 3)
        Console.WriteLine(multiply(4, 1));    // Output: 12 (c uses the default value 3)
        Console.WriteLine(multiply(4, 1, 1)); // Output: 4  (b and c are explicitly set to 1)
    

Impact on Code Reusability

The ability to define default parameters for lambda expressions greatly enhances their flexibility and reusability. Here’s how:

  • Simplifies Function Signatures: By allowing default values, lambdas can handle more use cases with fewer parameters, reducing the need for multiple overloads or complex parameter checking within the lambda.
  • Increases Readability: Default parameters make the code easier to read and understand, as the default behavior is clearly defined at the lambda declaration site.
  • Reduces Boilerplate Code: Developers no longer need to write additional logic to handle optional parameters, resulting in cleaner and more maintainable code.

Example of Improved Reusability

A. Without Default Parameters:


        Func<int, int=""> addFive = x => x + 5;
        Func<int, int,="" int=""> add = (x, y) => x + y;

        Console.WriteLine(addFive(10)); // Output: 15
        Console.WriteLine(add(10, 20)); // Output: 30
    

B. With Default Parameters:


        Func<int, int,="" int=""> add = (x, y = 5) => x + y;

        Console.WriteLine(add(10));    // Output: 15 (y uses the default value 5)
        Console.WriteLine(add(10, 20)); // Output: 30 (y is explicitly set to 20)
    

This enhancement in C# 12 empowers developers to write more adaptable and concise code, leveraging the full potential of lambda expressions with minimal effort.

4. Alias Any Type with ‘using’

Type aliases in C# allow you to create alternative names for existing types, making your code more readable and easier to understand. By using the using directive, you can give a type a more meaningful or concise name, which helps clarify the purpose of the type in your code. This can be particularly useful in large projects where certain types are used frequently or where types have long, complex names.

Benefits of Type Aliases

  • Improved Readability: By giving types more descriptive names, the code becomes easier to read and understand.
  • Reduced Typing: Aliases can shorten lengthy type names, making code easier to write and maintain.
  • Enhanced Clarity: Aliases can make the code’s intent clearer, helping new developers understand the codebase more quickly.

Enhancements in C# 12

In C# 12, the using alias directive has been extended to allow aliases for all types, including tuples, pointers, and other unsafe types. This expansion provides C Sharp developers with even more flexibility in defining meaningful type names, further enhancing code clarity and maintainability.

Example of Extending ‘using’ Alias Directive


        // Defining an alias for a tuple type
        using Measurement = (string Unit, int Value);

        // Defining an alias for a pointer type
        using IntPtr = int*;

        // Using the aliases
        Measurement distance = ("meters", 100);
        Console.WriteLine($"Distance: {distance.Value} {distance.Unit}");

        unsafe
        {
            IntPtr p = &distance.Value;
            Console.WriteLine($"Pointer value: {*p}");
        }
    

Examples and Use Cases

A. Creating Aliases for Different Types

Alias for a Tuple Type:


        using Coordinates = (double Latitude, double Longitude);

        Coordinates location = (37.7749, -122.4194);
        Console.WriteLine($"Location: {location.Latitude}, {location.Longitude}");

    

Alias for an Array Type:


        using StringArray = string[];

        StringArray names = { "Alice", "Bob", "Charlie" };
        Console.WriteLine($"Names: {string.Join(", ", names)}");
    

Alias for a Complex Type:


        using CustomerDictionary = Dictionary<int, (string="" name,="" int="" age)="">;

        CustomerDictionary customers = new()
        {
            [1] = ("Alice", 30),
            [2] = ("Bob", 25)
        };

        foreach (var customer in customers)
        {
            Console.WriteLine($"ID: {customer.Key}, Name: {customer.Value.Name}, Age: {customer.Value.Age}");
        }
    

B. Practical Examples Showing Improved Code Clarity and Semantics

Before Using Type Aliases:


        Dictionary<int, (string="" name,="" int="" age)=""> customers = new()
        {
            [1] = ("Alice", 30),
            [2] = ("Bob", 25)
        };
    

After Using Type Aliases:


        using CustomerDictionary = Dictionary<int, (string="" name,="" int="" age)="">;

        CustomerDictionary customers = new()
        {
            [1] = ("Alice", 30),
            [2] = ("Bob", 25)
        };
    

In the example above, using a type alias (CustomerDictionary) makes the code more readable by providing a meaningful name that describes the purpose of the dictionary.

C. Comparison with Previous Versions

In earlier versions of C#, the using alias directive was limited to named types. With C# 12, you can now alias any type, including tuples and pointers, which greatly enhances code maintainability and readability.

Previous Versions:


        using Customers = System.Collections.Generic.Dictionary<int, (string="" name,="" int="" age)="">;

        Customers customers = new()
        {
            [1] = ("Alice", 30),
            [2] = ("Bob", 25)
        };
    

C# 12:


        using CustomerDictionary = Dictionary<int, (string="" name,="" int="" age)="">;

        CustomerDictionary customers = new()
        {
            [1] = ("Alice", 30),
            [2] = ("Bob", 25)
        };
   

The expanded aliasing capability in C# 12 simplifies complex type definitions and makes the code more maintainable by allowing more descriptive and concise type names.

5. Inline Arrays

Inline arrays in C# 12 allow you to define fixed-size arrays directly within structs. This feature is particularly useful for performance-critical applications because it enables more efficient memory usage and faster access times compared to traditional arrays. Inline arrays can be used in scenarios where you need a fixed-size buffer or need to work with memory in a highly optimized way.

Typical Use Cases:

  • Memory Buffers: When you need to handle fixed-size memory blocks efficiently.
  • Embedded Systems: Where memory constraints are strict and performance is crucial.
  • Performance-Critical Applications: Such as game development or real-time data processing.

Syntax and Examples:

Inline arrays are declared within a struct using a specific attribute, allowing you to work with fixed-size arrays more safely and efficiently.

A. Syntax for Defining Inline Arrays:


        [System.Runtime.CompilerServices.InlineArray(10)]
        public struct InlineBuffer
        {
            private int _element0;
            // Elements _element1, _element2, ... _element9 are implicitly defined
        }
    

B. Example of Using Inline Arrays:


        [System.Runtime.CompilerServices.InlineArray(10)]
        public struct InlineBuffer
        {
            private int _element0;
            // The rest of the elements are managed by the compiler
        }

        public class Program
        {
            public static void Main()
            {
                InlineBuffer buffer = new InlineBuffer();
                for (int i = 0; i < 10; i++)
                {
                    buffer[i] = i;
                }

                foreach (var value in buffer)
                {
                    Console.WriteLine(value);
                }
            }
        }
    

In this example, an inline array of integers is defined within the InlineBuffer struct. The array has a fixed size of 10 elements, and the syntax for accessing and manipulating these elements is straightforward.

Benefits of Inline Arrays in Terms of Performance

A. Memory Efficiency:

  • Reduced Overhead: Inline arrays eliminate the need for separate memory allocations for the array, reducing overhead and fragmentation.
  • Cache Optimization: Since the array is stored inline within the struct, accessing its elements can be faster due to better cache locality.

B. Performance Improvements:

  • Faster Access: Accessing elements in inline arrays is faster because there is no need to follow pointers to separate heap-allocated memory blocks.
  • Predictable Performance: The fixed size of inline arrays ensures predictable performance characteristics, which is crucial for real-time applications.

Impact on High-Performance Applications

Inline arrays are particularly beneficial in high-performance applications where memory usage and access speed are critical factors. Here’s how they optimize performance:

A. Optimized Memory Usage:

  • Compact Storage: By storing the array directly within the struct, inline arrays use memory more compactly, reducing the overall memory footprint.
  • Less Overhead: The absence of additional heap allocations means less garbage collection overhead and reduced memory management complexity.

B. Faster Access Speed:

  • Improved Cache Locality: Inline arrays benefit from better cache locality, meaning that accessing sequential elements is faster as they are likely to be loaded into the CPU cache together.
  • Direct Access: Elements in inline arrays are accessed directly without pointer dereferencing, leading to faster read and write operations.

Example in High-Performance Context:


        [System.Runtime.CompilerServices.InlineArray(256)]
        public struct HighPerformanceBuffer
        {
            private byte _element0;
            // Implicitly defines _element1 to _element255
        }

        public class HighPerformanceApp
        {
            private HighPerformanceBuffer buffer;

            public void ProcessData()
            {
                for (int i = 0; i < 256; i++)
                {
                    buffer[i] = (byte)(i % 256);
                }

                // Perform high-performance operations with the buffer
            }
        }

    

In this high-performance application example, a HighPerformanceBuffer struct with an inline array of 256 bytes is used. This setup is ideal for scenarios requiring fast data processing and minimal memory overhead, such as real-time signal processing or game development.

Inline arrays in C# 12 offer a powerful tool for developers working on performance-critical applications. By enabling efficient memory usage and faster access speeds, inline arrays can significantly enhance the performance of applications that require fixed-size buffers or optimized memory handling.

6. Interpolated Strings Improvements

Interpolated strings, introduced in C# 6, allow you to embed expressions directly within string literals. This feature simplifies string formatting by letting you include variable values or expressions inside a string, using curly braces {}. It enhances readability and reduces the need for cumbersome concatenation or String.Format methods.

Example of Basic Interpolated String:


        int age = 25;
        string name = "Alice";
        string message = $"Hello, my name is {name} and I am {age} years old.";
        Console.WriteLine(message); // Output: Hello, my name is Alice and I am 25 years old.
    

Enhancements in C# 12

C# 12 introduces improvements that allow for more complex expressions within interpolated strings. These enhancements enable C Sharp developers to include intricate calculations, method calls, and even multiple expressions within the interpolation braces. This flexibility simplifies dynamic string creation and improves code readability.

Examples and Use Cases

A. Syntax for Complex Interpolated Strings

With C# 12, you can now include more complex expressions inside interpolated strings. This can involve arithmetic operations, method calls, or even nested interpolations.

Example of Complex Interpolated String:


        int age = 25;
        string name = "Alice";
        string message = $"Hello, my name is {name} and I am {age} years old.";
        Console.WriteLine(message); // Output: Hello, my name is Alice and I am 25 years old.
    

B. Practical Examples Showcasing Dynamic String Creation

Example 1: Arithmetic Operations:


        int a = 7;
        int b = 3;
        string calculation = $"When you add {a} and {b}, you get {a + b}. When you subtract them, you get {a - b}.";
        Console.WriteLine(calculation); // Output: When you add 7 and 3, you get 10. When you subtract them, you get 4.
    

Example 2: Method Calls:


        public string Greet(string name) => $"Hello, {name}";

        string name = "Bob";
        string message = $"Greeting: {Greet(name)}. Today's date is {DateTime.Now:MMMM dd, yyyy}.";
        Console.WriteLine(message); // Output: Greeting: Hello, Bob. Today's date is May 15, 2024.

    

Example 3: Nested Interpolations:


        int x = 5;
        int y = 6;
        string nested = $"The product of {x} and {y} is {x * y}, and the result multiplied by 2 is {(x * y) * 2}.";
        Console.WriteLine(nested); // Output: The product of 5 and 6 is 30, and the result multiplied by 2 is 60.
    

Benefits for C# Developers

The improvements to interpolated strings in C# 12 offer several advantages:

  • Enhanced Readability: By embedding complex expressions directly within strings, the code becomes easier to read and understand, reducing the cognitive load on developers.
  • Reduced Boilerplate Code: These enhancements minimize the need for auxiliary variables or separate method calls, leading to cleaner and more concise code.
  • Improved Maintainability: Interpolated strings with complex expressions consolidate related logic in one place, making the code easier to maintain and less prone to errors.

Example of Improved Readability and Maintainability:

Before C# 12:


        int num1 = 10;
        int num2 = 20;
        int sum = num1 + num2;
        string message = "The sum of " + num1 + " and " + num2 + " is " + sum + ".";
        Console.WriteLine(message); // Output: The sum of 10 and 20 is 30.
    

With C# 12:


        int num1 = 10;
        int num2 = 20;
        string message = $"The sum of {num1} and {num2} is {num1 + num2}.";
        Console.WriteLine(message); // Output: The sum of 10 and 20 is 30.
    

These enhancements make interpolated strings a powerful tool for developers, simplifying string manipulation and enhancing the overall quality of the code.

7. Enhanced Switch Expressions

Switch expressions in C# were introduced to simplify complex conditional logic and make code more concise and readable. Unlike traditional switch statements, which require break statements and can be verbose, switch expressions allow for more compact and expressive syntax. They evaluate a single expression and return a value based on pattern matching, making them ideal for scenarios where you need to perform different actions based on the value of a variable.

Basic Example of a Switch Expression:


        int number = 5;
        string description = number switch
        {
            1 => "One",
            2 => "Two",
            3 => "Three",
            4 => "Four",
            5 => "Five",
            _ => "Unknown"
        };
        Console.WriteLine(description); // Output: Five
    

New Pattern-Matching Syntax in C# 12

C# 12 introduces new pattern-matching syntax to make switch expressions even more powerful and concise. These enhancements simplify common patterns and improve the readability of the code.

A. Simplified Syntax for Common Patterns

Example of Simplified Pattern Matching:


        object obj = 42;
        string result = obj switch
        {
            int i when i > 0 => "Positive integer",
            int i when i < 0 => "Negative integer",
            int => "Zero",
            string s when !string.IsNullOrEmpty(s) => "Non-empty string",
            string => "Empty string",
            null => "Null",
            _ => "Unknown type"
        };
        Console.WriteLine(result); // Output: Positive integer
    

In this example, the new syntax simplifies pattern matching by allowing concise expressions and conditions directly within the switch expression.

B. Examples Demonstrating the New Syntax

Example 1: Handling Multiple Conditions:


        int number = -5;
        string description = number switch
        {
            > 0 => "Positive",
            < 0 => "Negative",
            0 => "Zero",
            _ => "Unknown"
        };
        Console.WriteLine(description); // Output: Negative
    

Example 2: Complex Type Matching:


        object shape = new { Type = "Circle", Radius = 5 };
        string shapeDescription = shape switch
        {
            { Type: "Circle", Radius: > 0 } => "A valid circle",
            { Type: "Rectangle", Width: > 0, Height: > 0 } => "A valid rectangle",
            _ => "Unknown shape"
        };
        Console.WriteLine(shapeDescription); // Output: A valid circle
    

Comparison with Previous Syntax

The new pattern-matching syntax in C# 12 reduces verbosity and enhances code clarity compared to previous versions.

Previous Syntax (Before C# 12):


        object obj = 42;
        string result;
        switch (obj)
        {
            case int i when i > 0:
                result = "Positive integer";
                break;
            case int i when i < 0:
                result = "Negative integer";
                break;
            case int:
                result = "Zero";
                break;
            case string s when !string.IsNullOrEmpty(s):
                result = "Non-empty string";
                break;
            case string:
                result = "Empty string";
                break;
            case null:
                result = "Null";
                break;
            default:
                result = "Unknown type";
                break;
        }
        Console.WriteLine(result); // Output: Positive integer        
    

New Syntax (C# 12):


        object obj = 42;
        string result = obj switch
        {
            int i when i > 0 => "Positive integer",
            int i when i < 0 => "Negative integer",
            int => "Zero",
            string s when !string.IsNullOrEmpty(s) => "Non-empty string",
            string => "Empty string",
            null => "Null",
            _ => "Unknown type"
        };
        Console.WriteLine(result); // Output: Positive integer
    

The new syntax eliminates the need for multiple case blocks and break statements, making the code more concise and easier to understand.

Benefits for Developers

The enhancements to switch expressions in C# 12 provide several benefits:

  • Increased Readability: The new syntax makes conditional logic more straightforward and easier to follow.
  • Reduced Boilerplate Code: By eliminating unnecessary case blocks and break statements, the code becomes cleaner and more maintainable.
  • Enhanced Flexibility: The ability to use complex patterns and conditions directly within the switch expression allows for more expressive and powerful code.

8. Async Streams

Async streams in C# allow for asynchronous iteration over data streams. They enable you to work with data sequences that are produced asynchronously, which is especially useful when dealing with I/O-bound operations like reading data from a network, files, or other external sources. Async streams use the IAsyncEnumerable interface, which provides an asynchronous version of the traditional IEnumerable interface. This allows for non-blocking iteration over data, improving the efficiency and responsiveness of applications.

Basic Example of Async Stream:


        public async IAsyncEnumerable GetNumbersAsync()
            {
                for (int i = 1; i <= 5; i++)
                {
                    await Task.Delay(1000); // Simulate asynchronous operation
                    yield return i;
                }
            }
            
            // Consuming the async stream
            await foreach (var number in GetNumbersAsync())
            {
                Console.WriteLine(number);
            }
        // Output: 1 2 3 4 5 (with a 1-second delay between each number)            
    

Enhancements in C# 12

C# 12 introduces improvements that enhance the handling of async streams, making them more powerful and easier to use. These enhancements include better support for cancellation, improved performance, and more flexible syntax for defining and consuming async streams.

Key Enhancements:

  • Cancellation Support: Improved mechanisms for handling cancellation tokens within async streams.
  • Performance Improvements: Optimizations that reduce overhead and improve the efficiency of async stream operations.
  • Enhanced Syntax: More intuitive and flexible syntax for defining and iterating over async streams.

Examples and Use Cases

A. Syntax for Async Streams

The syntax for defining async streams involves using the IAsyncEnumerable interface and the yield return statement within an asynchronous method. To consume async streams, the await foreach loop is used.

Defining an Async Stream:


        public async IAsyncEnumerable GetNumbersAsync()
            {
                for (int i = 1; i <= 5; i++)
                {
                    await Task.Delay(1000); // Simulate asynchronous operation
                    yield return i;
                }
            }                      
    

Consuming an Async Stream:


        await foreach (var number in GetNumbersAsync())
        {
            Console.WriteLine(number);
        }      
    

B. Practical Examples Showing Asynchronous Data Iteration

Example 1: Reading Lines from a File Asynchronously:


        public async IAsyncEnumerable ReadLinesAsync(string filePath)
            {
                using var reader = new StreamReader(filePath);
                while (!reader.EndOfStream)
                {
                    yield return await reader.ReadLineAsync();
                }
            }
            
            await foreach (var line in ReadLinesAsync("example.txt"))
            {
                Console.WriteLine(line);
            }       
    

Example 2: Fetching Data from an API Asynchronously:


        public async IAsyncEnumerable FetchDataFromApiAsync(string apiUrl)
            {
                using var client = new HttpClient();
                var response = await client.GetAsync(apiUrl);
                response.EnsureSuccessStatusCode();
            
                using var stream = await response.Content.ReadAsStreamAsync();
                using var reader = new StreamReader(stream);
                while (!reader.EndOfStream)
                {
                    yield return await reader.ReadLineAsync();
                }
            }
            
            await foreach (var data in FetchDataFromApiAsync("https://api.example.com/data"))
            {
                Console.WriteLine(data);
            }                 
    

Benefits for Asynchronous Programming

Async streams provide several benefits for handling asynchronous data flows:

  • Non-Blocking Iteration: Async streams allow you to iterate over data without blocking the main thread, which is crucial for maintaining the responsiveness of applications, especially in UI or server-side contexts.
  • Improved Resource Management: By leveraging async streams, you can better manage resources like network connections and file handles, ensuring they are used efficiently and released promptly.
  • Enhanced Readability and Maintainability: The syntax for async streams makes the code more readable and easier to maintain. The await foreach loop closely resembles traditional iteration patterns, making it intuitive for C# developers familiar with synchronous code.
  • Flexibility: Async streams can be used in a variety of scenarios, from reading files and fetching data from APIs to processing data pipelines and handling event streams.

9. Experimental Features

Experimental features in C# are new language features or enhancements that are introduced to gather feedback and test their viability before they are fully integrated into the language. These features are marked as experimental and are subject to change based on user feedback and further development. The primary goal of experimental features is to allow C Sharp developers to try out new capabilities and provide feedback to the C# team, helping shape the future of the language.

Key Experimental Features in C# 12

A. ExperimentalAttribute for Marking Experimental Features

The ExperimentalAttribute is a new attribute introduced in C# 12 that can be used to mark types, methods, or assemblies as experimental. This attribute indicates that a feature is not yet stable and may undergo significant changes or be removed in future versions. When a C Sharp developer uses an experimental feature, the compiler issues a warning, making it clear that the feature is experimental.

Example of ExperimentalAttribute:


        using System.Diagnostics.CodeAnalysis;

        [Experimental("ExperimentalFeature", DiagnosticId = "EXP001")]
        public class ExperimentalClass
        {
            public void ExperimentalMethod()
            {
                Console.WriteLine("This is an experimental method.");
            }
        }
    

Consuming Experimental Features:


        var experimentalInstance = new ExperimentalClass();
        experimentalInstance.ExperimentalMethod(); // Compiler issues a warning about the experimental feature
    

B. Interceptors for Method Call Redirection

Interceptors are an experimental feature that allows method calls to be redirected. This can be useful for scenarios such as logging, profiling, or modifying the behavior of methods without changing their code. Interceptors enable developers to inject additional behavior into method calls dynamically.

Example of Using Interceptors:


        [Experimental("Interceptors", DiagnosticId = "EXP002")]
        public class InterceptorExample
        {
            public void OriginalMethod()
            {
                Console.WriteLine("Original method execution.");
            }
        }

        // Example interceptor to log method calls
        public class LoggingInterceptor
        {
            public void Intercept(MethodInfo method, object[] args)
            {
                Console.WriteLine($"Intercepted call to {method.Name} with arguments: {string.Join(", ", args)}");
            }
        }
    

Using the Interceptor:


        var interceptor = new LoggingInterceptor();
        var example = new InterceptorExample();

        // Simulate method interception (this would be handled by the runtime in a real scenario)
        interceptor.Intercept(typeof(InterceptorExample).GetMethod("OriginalMethod"), new object[0]);
        example.OriginalMethod();
    

C. Examples and Use Cases

Example 1: ExperimentalAttribute in Real-World Scenario

A company is developing a new feature for their application but wants to gather feedback before finalizing it. They mark the new classes and methods with ‘ExperimentalAttribute’.


        [Experimental("BetaFeature", DiagnosticId = "EXP003")]
        public class BetaFeature
        {
            public void BetaMethod()
            {
                Console.WriteLine("This is a beta feature.");
            }
        }

        // Developers using the beta feature receive a compiler warning
        var beta = new BetaFeature();
        beta.BetaMethod();
    

Example 2: Using Interceptors for Logging


        public class Calculator
        {
            public int Add(int a, int b) => a + b;
            public int Subtract(int a, int b) => a - b;
        }

        public class LoggingInterceptor
        {
            public void Intercept(MethodInfo method, object[] args)
            {
                Console.WriteLine($"Method {method.Name} called with arguments: {string.Join(", ", args)}");
            }
        }

        // Interceptor usage (simulated for illustration purposes)
        var calculator = new Calculator();
        var interceptor = new LoggingInterceptor();
        interceptor.Intercept(typeof(Calculator).GetMethod("Add"), new object[] { 5, 3 });
        Console.WriteLine(calculator.Add(5, 3)); // Output: Method Add called with arguments: 5, 3
    

A developer wants to add logging to all method calls in a class without modifying the class itself. They use an interceptor to log method calls.

Guidance for C# Developers

When using experimental features, developers should keep the following guidelines in mind:

  • Be Cautious: Experimental features are not stable and may change or be removed in future releases. Use them with caution, especially in production code.
  • Provide Feedback: Engage with the C# team by providing feedback on experimental features. Your input helps improve the language and shape its future direction.
  • Mark Usage Clearly: Clearly document and mark the use of experimental features in your codebase to alert other developers and maintainers about their experimental nature.
  • Stay Updated: Keep track of updates and changes to experimental features by following official C# and .NET announcements and documentation.

By understanding and appropriately using experimental features, developers can contribute to the evolution of C# while exploring cutting-edge capabilities.

You May Also Read: Deciding Between C# and Python: Which Suits Your Project Better?

Conclusion

C# 12 introduces several powerful features designed to make coding more efficient and enjoyable. These enhancements position C# as an even more powerful and versatile language, capable of meeting the evolving needs of modern software development. As C Sharp developers adopt these features, we can expect a shift towards more readable, maintainable, and efficient codebases. The improvements in asynchronous programming, memory management, and pattern matching will particularly benefit applications that require high performance and responsiveness. Additionally, the experimental features indicate a forward-looking approach, allowing the C# community to actively shape the language’s future. For a comprehensive overview of all the new features, refer to Microsoft’s guide on “ What’s new in C# 12“.

Looking to leverage the latest features of C# 12 in your projects? Contact us today for expert C# development services, whether you need project-based solutions or dedicated teams to drive your success.

Sanjay Singhania, Project Manager

Sanjay, a dynamic project manager at Capital Numbers, brings over 10 years of experience in strategic planning, agile methodologies, and leading teams. He stays updated on the latest advancements in the digital realm, ensuring projects meet modern tech standards, driving innovation and excellence.

Share

Recent Awards & Certifications

  • World HRD Congress
  • G2
  • Dun and Bradstreet
  • ISO 9001 & 27001
  • SOC2
[class^="wpforms-"]
[class^="wpforms-"]
[class^="wpforms-"]
[class^="wpforms-"]