Skip to main content

C# Features Demo

Setup

We are gonna create a few projets to demonstrate the new features in C# 11, 12, and 13.

  1. Create a new console app called CSharp11

    mkdir CSharp
    cd CSharp
    mkdir CSharp11
    cd CSharp11
    dotnet new console
  2. Create a new console app called CSharp12

    mkdir CSharp12
    cd CSharp12
    dotnet new console
  3. Create a new console app called CSharp13

    mkdir CSharp13
    cd CSharp13
    dotnet new console
  4. Create a new solution

    dotnet new sln
    dotnet sln add ./CSharp11
    dotnet sln add ./CSharp12
    dotnet sln add ./CSharp13
  5. Open the solution in your IDE of choice.

C# 11

Records

Create a person record

The default is a class record but can also be a struct record.

Record features:

  • compiler generates a special kind of class
  • fields will by default be public properties
  • value based semantics - if I have 2 records with the same values and I compare them, c# will say they are the same. If I had 2 classes, they won't
  • immutability - once created can't change values

Records make it harder to make mistakes - so your code is more robust and maintainable.

record Person(string FirstName, string LastName);

create a new person

var p1 = new Person("Gert", "Marx");

Records are immutable and the following will not work

p1.FirstName = "Fred";

However, we can update a copy of the person using 'with'

var p2 = p1 with { FirstName = "Fred" };

We also get value based equality

var doppleGanger = new Person("Gert", "Marx");
if (p1 == doppleGanger)
{
Console.WriteLine("Equal!");
}

Real world example

var originalTx = new BankTransaction(
TransactionId: "TX123",
Amount: 250.00m,
FromAccount: "123-456",
ToAccount: "789-012",
Timestamp: DateTime.UtcNow
);

// Create a reversed transaction (e.g. refund)
var refundTx = originalTx with {
TransactionId = "TX124",
FromAccount = originalTx.ToAccount,
ToAccount = originalTx.FromAccount,
Amount = -originalTx.Amount,
Timestamp = DateTime.UtcNow
};
Can't I do the same thing with a class?

Yes, but look at all the boilerplate code we have to write to get the same functionality:

Records are designed to reduce boilerplate code and make your code cleaner and more maintainable.

Record

public record Person(string FirstName, string LastName);

Class

public class Person
{
public string FirstName { get; }
public string LastName { get; }

public Person(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}

public override bool Equals(object? obj)
{
if (obj is not Person other) return false;

return FirstName == other.FirstName
&& LastName == other.LastName;
}

public override int GetHashCode() =>
HashCode.Combine(FirstName, LastName);

public override string ToString() =>
$"Person: {FirstName} {LastName}";
}

Required and Init Properties - code more robust

Create the following class

public class Foo
{
public string Bar { get; set; }
}

Notice how we get a warning around 'Bar' being uninitialized. This is because we have nullable reference types turned on by default. This means the compiler will try to warn us against any properties that could possibly be null.

We can get around this by using the required keyword.


public required string Bar { get; set; }

Now this removes the warning, but it also means we can set this property anywhere in code (which we often don't want).

    var foo = new Foo { Bar = "Bar " };
foo.Bar = "Bar 2"; // 🤢

To get around this we can use the init keyword.

public class Foo
{
public required string Bar { get; init; }
}

Now we have a nicely constructed object that is guaranteed to have a value for Bar and we can't change it after construction.

For now, we'll move the class to another file so it doesn't break our demo.

Raw String Literals - 3 or more qoutes

Raw string literals were created to make it easier to have nicely formatted blocks of code from other formats.

var xml = """
<html>
<body>
<p>Hello, World!</p>
</body>
</html>
""";

We can also use interpolation with these strings.

We can use any number of quotes to start and end the string, as long as the start and ends match.

var name = "bar";
var sql =
$$"""""
SELECT *
FROM Foo
WHERE Bar = '{{name}}'
""""";

When printed out, all the white space gets trimmed.

Patterns

Next we're going to move onto patterns. There are many different types of patterns in C#. Some you've most likely used before, but may not realise they're patterns.

Null Pattern

First is the null pattern.

if (foo is not null)
{
Console.WriteLine(foo.Bar);
}

Type pattern

if (foo is Foo)
{
Console.WriteLine("Foo");
}

Property pattern

if (foo is { Bar: "Bar " })
{
Console.WriteLine("Bar");
}

Switch expression / Discard Pattern

var result1 = foo switch
{
{ Bar: "Foo " } => "Foo",
{ Bar: "Bar" } => "Bar",
_ => "Default" // Discard Pattern
};

Relational Pattern

int age = 30;
var result2 = age switch
{
< 18 => "Child",
>= 18 and <= 65 => "Adult",
> 65 => "Senior",
};

Positional Pattern

Positional patterns allow you to destructure and match patterns based on the properties or elements of an object or tuple

var result3 = foo switch
{
(string bar, _) => bar,
_ => "Default"
};

These patterns can also be combined. For example, you may use the positional pattern to destructure an object, then the relational pattern to perform tests on the properties.

C# 12

Primary Constructors

The major new C# 12 feature added is Primary Constructors. Let's take a look at what a class looks like with and without this feature.

Create a hero class as follows:

public class Hero
{
private string _name;
private int _age;
private string _superPower;

public Hero(string name, int age, string superPower)
{
_name = name;
_age = age;
_superPower = superPower;
}

public override string ToString()
{
return $"Hero: {_name}, Age: {_age}, SuperPower: {_superPower}";
}
}

Now, if we refactor this to use Primary Constructors, it looks like this:

public class Hero2(string name, int age, string superPower)
{
public override string ToString()
{
return $"Hero: {name}, Age: {age}, SuperPower: {superPower}";
}
}

This can be especially useful to remove code we'd usually have to deal with dependency injection.

NOTE: Primary Constructors may look similar to records, but they operate quite differently. With records the params are turned into properties. With Primary Constructors, they are parameters.

You can use the parameters directly in methods like ToString(), but you can’t access them from outside or through this unless you expose them.

Show how we can't use 'this' to reference the parameters.

Collection Expressions

Over the years arrays and lists have been getting easier to initialize in C# by becoming simpler to write.

Collection expressions are the next (and probably the last) evolution of this.

We can initialize a list of simple numbers as follows.

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

Show how we can't use var - It needs to know the type of the list at compile time.

Now let's create 3 functions that take in different types of lists

void Foo(IEnumerable<int> numbers)
{
}

void Foo2(List<int> numbers)
{
}

void Foo3(int[] numbers)
{
}

we can use list expressions for all of these functions as follows:

Foo([1,2,3]);
Foo([]);
Foo2([1,2,3]);
Foo3([1,2,3]);

Spread Operator

The spread operator is a new operator that allows us to join two lists together.

List<int> lowNumbers = [1, 2, 3];
List<int> highNumbers = [4, 5, 6];
List<int> allNumbers = [..lowNumbers, ..highNumbers];

Lambda defaults

The last feature we'll look at is lambda defaults. This is not a huge feature, but may be handy from time to time.

var lambda = (int start = 0, int end = 10) => Console.WriteLine("Start: {0}, End: {1}", start, end);
lambda();
lambda(2, 5);

C#13

Param Collections

C# 13 introduces a new way to define methods that can take a variable number of parameters using the params keyword. This allows you to pass an array of arguments to a method without explicitly creating an array.

public void PrintNumbers(params int[] numbers)
{
Console.WriteLine("Numbers Count: " + numbers.Length);

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

Field (Preview)

C# 13 introduces a new feature called "Field" that allows you to define fields in a class or struct. This feature is currently in preview and may change in future versions of C#.

Lets you reference the backing field without having to define it explicitly.

    <LangVersion>preview</LangVersion>
public class Person
{
public required string Name { get; set; }
public required int Age
{
get;
set => field = value < 0 ? 0 : value;
}
public required string Address { get; set; }
public required string PhoneNumber { get; set; }
}

Partial Properties

  • Probably only useful fror source generated code
  • Separates Definition from Implementation - source gen might define a property, and then you can implement behavior in another file.
partial class Person
{
public partial string Name { get; set; } // definition
}

partial class Person
{
public partial string Name
{
get;
set => field = value; // implementation
}
}

New Lock Type

C# 13 introduces a new lock type that allows you to lock on a specific object instead of the entire class. This feature is useful for improving performance and reducing contention in multi-threaded applications

var counterWithoutLock = 0;

Console.WriteLine("Running demo...");

// No lock (should be incorrect)
var noLockTasks = new Task[10];
for (int i = 0; i < noLockTasks.Length; i++)
{
noLockTasks[i] = Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
counterWithoutLock++; // Race condition!
}
});
}
await Task.WhenAll(noLockTasks);


// With lock (should be correct)

var counterWithLock = 0;
var myLock = new Lock();

var lockTasks = new Task[10];
for (int i = 0; i < lockTasks.Length; i++)
{
lockTasks[i] = Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
lock (myLock)
{
counterWithLock++;
}
}
});
}
await Task.WhenAll(lockTasks);

Console.WriteLine($"Without lock: {counterWithoutLock} ❌ (should be 10,000)");
Console.WriteLine($"With lock : {counterWithLock} ✅ (correct)");

Init Array with Index Operator (^)

C# 13 introduces a new way to initialize arrays using the index operator ^. This allows you to create arrays with a specific size and initialize them with default values.

var timer = new CountdownTimer()
{
Buffer =
{
[^1] = 0,
[^3] = 2,
[^2] = 1,
[^4] = 3,
[^5] = 4,
}
};

Console.WriteLine(string.Join(", ", timer.Buffer));

class CountdownTimer
{
public int[] Buffer { get; set; } = new int[5];
}