Mordern Language Features in C#

Code Conciseness and New Features

1. Pattern Matching, Mordern Switch Expression and Expression-Bodied Member

These are powerful syntaxes, they liberate code from cumbersome if-else chains.

  • Pattern matching:

    • type pattern
    • property pattern
    • list pattern
    • { }
  • Switch expression

    • hightly recommended for returning a value based on pattern matching.
    • when switch logic effectively functions as a mapping from an input to an output.
    • for concise, immutbale assignments.
    • leveraging new pattern features like property, positional, relational, and logical patterns
    • when compiler exhaustiveness checking is desired to ensure all cases are handled.
      The evolution of the switch construct in C# has made it a vastly more powerful and expressive tool for conditional logic, moving it from a simple constant dispatcher to a sophisticated pattern-matching engine.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
internal static class ShapeProcessor
{

// using the pattern matching feature introduced in C# 7.0
public static string ProcessShapePatternMatching(Object shape)
{
// Using pattern matching to check the type of shape
if (shape is Circle circle)
{
return $"Circle with radius: {circle.Radius}";
}
else if (shape is Ractangle rectangle)
{
return $"Rectangle with width: {rectangle.Width} and height: {rectangle.Height}";
}
else if (shape is Shape)
{
return "Unknown shape";
}
else
{
return "Unknown object";
}
}

// using the switch expression feature introduced in C# 8.0
public static string ProcessShapeSwitchExpression(Object shape)
{
return shape switch
{
Circle c => $"Circle with radius: {c.Radius}",
Ractangle r => $"Ractangle with width: {r.Width}, height: {r.Height}",
Shape s => $"Unknown shape",
_ => "Unknown object"
};
}

// using expression-bodied member introduced in C# 7.0
public static string ProcessShapeExpressionBodiedMember(Object shape) =>
shape switch
{
Circle c => $"Circle with radius: {c.Radius}",
Ractangle r => $"Ractangle with width: {r.Width}, height: {r.Height}",
Shape s => $"Unknown shape",
_ => "Unknown object"
};

// using pattern matching with a property
public static string ProcessShapeWithPropertyPattern(Shape shape) =>
shape.Category switch
{
ShapeCategory.Circle => $"Circle with radius: {(shape as Circle)?.Radius}",
ShapeCategory.Rectangle => $"Rectangle with width: {(shape as Ractangle)?.Width}, height: {(shape as Ractangle)?.Height}",
ShapeCategory.Unknown => "Unknown shape",
_ => "Unknown object"
};
}

2. Advance Pattern Matching and more Switch Expression

  • List Pattern

    • Matching list of basic types
    • Matching list of mixed types
    • Matching list of properties
    • Matching list of types with properties
    • Matching list pattern with logical operator (and, or, not)
    • Matching list pattern with relational operator (<, >, <=, >=)
    • Matching list of recursive pattern
  • {}

    • check if an object is non-null
    • check the values of one or more of an object’s properties
    • recursively apply more patterns to those properties (including other property patterns, relational patterns, type patterns, etc.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 1. Simple Null Check/type Check(implicit)
object item = "Hello";
if (item is string {})// equivalent to item != null
{
//do something, if item is a non-null string
}

object? nullableItem = null;
if (nullableItem is object {})
{
//do something, if nullableItem is any object other than null
}

// 2. Property value matching
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string City { get; set; }
}
Person p1 = new Person { Name = "Alice", Age = 30, City = "New York" };
Person p2 = new Person { Name = "Bob", Age = 25, City = "London" };
Person p3 = new Person { Name = "Charlie", Age = 30, City = "Paris" };
if (p1 is Person { Name: "Alice", Age: 30 })
{
Console.WriteLine($"{p1.Name} is Alice and 30.");
}

string description = p3 switch
{
Person { Name: "Charlie", Age: 30 } => "It's Charlie, age 30.",
Person { Age: > 25 } => "It's someone older than 25.", // Relational pattern inside {}
_ => "Unknown person."
};
Console.WriteLine(description);

// 3. Nested property patterns (recursive patterns)
public class Address
{
public string Street { get; set; }
public string City { get; set; }
}
public class Customer
{
public string CustomerName { get; set; }
public Address HomeAddress { get; set; }
}
Customer cust = new Customer
{
CustomerName = "David",
HomeAddress = new Address { Street = "Main St", City = "New York" }
};
if (cust is Customer { CustomerName: "David", HomeAddress: { City: "New York" } })
{
Console.WriteLine($"{cust.CustomerName} lives in New York.");
}
  • Various switch expression with pattern matching
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    // Property pattern matching
    decimal CalculateDeliveryFee(Order order) => order switch
    {
    {TotalAmount: <50} => 5.00m,
    {TotalAmount: >=50} => 0.00m,
    - => throw new ArgumentException("Invalid order")
    }

    // Property pattern matching, or positional pattern, deconstruct an object
    string GetPointQuadrant(Point p) => p switch
    {
    (0, 0) => "Origin",
    (int x, int y) when x > 0 && y > 0 => "Quadrant I",
    (int x, int y) when x < 0 && y > 0 => "Quadrant II",
    (int x, int y) when x < 0 && y < 0 => "Quadrant III",
    (int x, int y) when x > 0 && y < 0 => "Quadrant IV",
    (_, 0) => "On X-axis", // Any X, Y is 0
    (0, _) => "On Y-axis", // X is 0, Any Y
    _ => "Unknown"
    };
    record Point(int X, int Y);

    // Relational Patterns
    string GetTemperatureCategory(double temp) => temp switch
    {
    < 0 => "Freezing",
    >= 0 and < 10 => "Cold",
    >= 10 and < 20 => "Mild",
    >= 20 => "Hot"
    };

    // Logical patterns

3. Primary Constructors

Simplifies constructor declarations by allowing to define parameters directly in the class or struct declaration.

  • Syntax

    • Omit explicit constructor body
    • Parameters on Class Signature
    • Parameters are in scope for the entire class body
    • Initialize properties(or fields) directly with parameters
  • Compiler behavior under the hood

    • Generate an implicit constructor - Primary Constructor
    • Compiler implicitly create a private backing field to store the parameter’s value, for property/field/instance method using
    • If parameters were only refered by base(), no implicit backing field will be created
  • Work with normal constructor

    • this() has to be called in any other explicitly declared constructors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
internal class Vehicle(string make, int year)
{
public string Make { get; } = make;
public decimal Year { get; private set; } = year;

public void DisplayVehicleInfo()
{
Console.WriteLine($"Vehicle:{Make}, Year: {Year}");
}

// Explicit constructor must call the primary constructor
public Vehicle(string make): this(make, DateTime.Now.Year)
{
Console.WriteLine($"Vehicle created with make '{make}' and current year.");
}
}

internal class Car(string make, int year, string model, string fuelType) : Vehicle(make, year)
{
public string Model { get; } = model;
public string FuelType { get; } = fuelType;

public int Odometer { get; set; }

public void DisplayCarInfo()
{
Console.WriteLine($"Car: {Make} {Model} ({Year}), Fuel: {FuelType}");
}

public Car(string make, int year, string model, string fuelType, int odometer) : this(make, year, model, fuelType)
{
Odometer = odometer;
Console.WriteLine($"Car created with odometer: {odometer}");
}

// Any explicit constructor must call the primary constructor
//public Car(string model):base("Unknown Make")
//{
// Model = model;
// FuelType = "Petrol";
//}
}

4. Collection Expressions

A concise syntax making code readable for arrays, lists, and spans.

1
2
3
4
5
6
7
8
9
10
11
12
Console.WriteLine("Collection Expressions");
// Traditional initialization
int[] numbers1 = new int[] { 1, 2, 3 };
List<string> names1 = new List<string> { "Alice", "Bob" };

// Using collection expressions
int[] numbers2 = [1, 2, 3, 4, 5];
List<string> names2 = ["Charlie", "David", "Eve"];
Span<int> spanOfNumbers = [10, 20, 30];

// Sprading existing collections
int[] moreNumbers = [.. numbers2, 6, 7];

5. Nullable Reference Types and “required” Keyword

  • Nullable Reference Types

    • Help to mitigate NullReferenceException errors by allowing to explicitly declare whether a reference type can be null.
    • Help to writing more robust and error-free code
    • Enabled via enablein .csproj or #nullable enable in file
    • It’s about whether a reference can legally be null at any point in its lifetime(not just at initialization)
    • This is a compile-time warning system to help prevent NullReferenceException.
      • declare a non-nullable reference type, but don’t initialize it to a non-null value by the time the constructor exits.
      • assign null to a non-nullable reference type.
      • dereference a nullable reference type without first checking for null
    • It don’t enforce initialization
  • required keyword

    • enforce that a property or field must be assigned a value, etiher
      • in the obejct initializer( new MyClass {Prop = value})
      • Call a constructor explicitly marked with attribute [SetRequiredMembers](which tells the compiler that this constructor intends to set all required members, though the compiler doesn’t verify its internal logic)
    • This is a compile-time check primarily about initialization, compile-time error will be raised in compile-time if a property didn’t meet above rule.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#nullable enable
internal class UserProfile
{
public string UserName { get; set; } ="Guest"; // Non-nullable, must be initialized
public string? Email { get; set; } // Nullable reference type, can be null
public string PhoneNumber { get; set; } = string.Empty; // Non-nullable, must be initialized

public void DisplayEmail()
{
if(Email != null)
{
Console.WriteLine($"User email: {Email.ToLower()}");
}
else
{
Console.WriteLine("User email is not provided.");
}
}
}

6. Records

Records are reference types that provide built-in functionality for working with immutable data (data-centric classes). They automatically generate methods like

  • Equals()
  • GetHashCode()
  • ToString()
    Good to be used to define data models, especially in functional programming paradigms.

Evolution from class to record:

  • A significant shift in how we model data
  • Especially when that data is primarily used for holding values and needs well-defined equality (“well-defined equality” refers to a precise and unambiguous definition of when two objects are considered to be the same mathematically, it’s not just about the symbol, but about the underlying properties and rules that govern when that symbol can be used truthfully.).
  • A common pain point in OOP: class is a reference, if want to compare the equality of values, we have to manually implement the Equals() and GetHashCode() methods,
  • With record, it automates these mundane tasks, providing a dedicated construct for types that embody “value semantics”. It simplifies the creation of immutable data transfer objects (DTOs), domain models, and other data structures.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
internal class OldProduct
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}

internal record Product(int Id, string Name, decimal Price)
{
// Record properties are immutable by default, but you can use init-only setters
// to allow setting them during object initialization.
//public int Id { get; init; } = Id;
//public string Name { get; init; } = Name;
//public decimal Price { get; init; } = Price;
//// You can add methods or additional functionality if needed
public void DisplayProductInfo()
{
Console.WriteLine($"Product ID: {Id}, Name: {Name}, Price: {Price:C}");
}
}

7. Default Interface Methods

It allows to add new members to interfaces without breaking existing implementations.
It’s useful for evolving APIs and providing default behavior for interface members.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface ILogger
{
void LogInfo(string message);
void LogError(string message, Exception ex);
// Default implementation for a new method without breaking existing implementers
public void LogWarning(string message) // Default Interface Method
{
Console.WriteLine($"[WARNING] {message}");
}
}
public class ConsoleLogger : ILogger
{
public void LogInfo(string message) => Console.WriteLine($"[INFO] {message}");
public void LogError(string message, Exception ex) => Console.WriteLine($"[ERROR] {
// No need to implement LogWarning if the default is sufficient
}
// Usage:
// ILogger logger = new ConsoleLogger(); //Have to use ILogger as the object type
// logger.LogInfo("Application started.");
// logger.LogWarning("Potential issue detected."); // Uses the default implementation

When good to use DIM:

  • API evolution and backward compatibility, it’s also the primary use case
  • Provider common helper methods
  • Interoperability with other languages/platforms, like Java and Swift with similar features.

When bad to use DIM:

  • Don’t use DIM to replace abstract classes
  • Don’t use DIM as a magic to hidden behavior. DIM should be simple, predictable and well-documented.
  • Don’t use it every where, there will be ambiguity problem
  • Don’t use it every where, can violate the interface segregation principle(ISP) from SOLID
  • It’s wrong to call DIM directly from implementing calss instance

8. Null-Coalescing Operators

  • Null-Coalescing Assignment Operator (??=)
    • Assigning a value to a variable only if that variable is currently null
    • It’s a shorthand for a common pattern of “initialize if null.”
    • Syntax: variable ??= expression;
      • if variable is not null, its value remains unchanged, the expression is not evaluated
      • if variable is null, variable is assigned the result of expression, the expression is evaluated.
    • Equivalent to if(variable == nul){variable = expression;}
1
2
3
4
string? userName = null; //null
userName ??= "Guest"; //Guest
userName ??= "Vistor"; //Guest
userName = "Alice"; //Alice
  • Null Coalescing Operator(??)
    • hadle null values by providing a default value when an expression evaluates to null
    • Syntax: expression1 ?? expression2
      • Evaluate expression1(the left-hand operand)
      • check for null
        • if expression1 is not null, then its value is the result of the entire ?? operation, expression2 is not evaluated.
        • if expression1 is null, then expression2(the right-hand operand) is evaluated, and its value is the result of the entire ?? operation.
      • Analogy: If the first thing exists(is not null), use it; otherwise, use the second thing.

Its use cases:

  • Providing default values for nullable function parameters, or initializing variables with fallback values:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    string? myValue = GetValueFromSomewhere();
    string result = myValue ?? myValue : "Defalut Value";

    //it is the concise version of
    string result = (myValue != null)? myValue:"Default Vaule";
    //and
    string result;
    if(myValue == null)
    {
    result = "Default Value";
    }
    else
    {
    result = myValue;
    }
  • Chaining for multiple fallbacks:
    1
    2
    3
    4
    // Try environment variable, then config file, then hardcoded default
    string? environmentVar = Environment.GetEnvironmentVariable("MY_APP_SETTING");
    string? configFileSetting = GetConfigFileSetting("MyAppSetting");
    string finalSetting = environmentVar ?? configFileSetting ?? "DefaultHardcodedValue";
  • Safely accessing properties/methods on nullable value types:
    1
    2
    int? age = null; //syntactic sugar for Nullable<int>
    int actualAge = age ?? 18;