Skip to main content

Appendix E - C# Cheatsheet

This cheatsheet provides a concise overview of C# up to version 14, focusing on core language fundamentals, data structures, and object-oriented programming principles. It includes new features introduced in C# 12, C# 13, and C# 14, with special attention to the latest language enhancements.

1. Core Language Fundamentals

1.1 Variables and Data Types

Variables in C# must be declared before they are used. C# is a statically-typed language, meaning the type of a variable is known at compile time.

Common Data Types:

  • Value Types: int, double, float, char, bool, decimal, struct, enum
  • Reference Types: string, object, class, interface, delegate, array
// Value types
int age = 30;
double price = 19.99;
bool isActive = true;
char initial = 'J';

// Reference types
string name = "John Doe";
object obj = null;

Nullable Value Types: Allow value types to hold null.

int? nullableInt = null;
double? nullableDouble = 12.5;

if (nullableInt.HasValue)
{
Console.WriteLine(nullableInt.Value);
}

Nullable Reference Types: Helps prevent NullReferenceException by providing warnings if a reference type might be null. Enabled at the project level or with #nullable enable.

#nullable enable
string? possiblyNullString = null;
string definitelyNotNullString = "Hello";

1.2 Operators and Expressions

C# supports a wide range of operators:

  • Arithmetic: +, -, *, /, %
  • Comparison: ==, !=, >, <, >=, <=
  • Logical: &&, ||, !
  • Assignment: =, +=, -=, *=, /=, %=
  • Bitwise: &, |, ^, ~, <<, >>
  • Null-coalescing: ??, ??=
  • Conditional (Ternary): ?:
  • Null-conditional access: ?., ?[]
int a = 10;
int b = 5;
int sum = a + b; // 15
bool areEqual = (a == b); // false
string result = (a > b) ? "a is greater" : "b is greater or equal";

// Null-coalescing
string? displayName = null;
string userName = displayName ?? "Guest"; // userName will be "Guest"

// Null-conditional access (C# 6+)
string? name = null;
int? length = name?.Length; // null if name is null
var firstChar = name?[0]; // null if name is null

// C# 14: Null-conditional assignment
List<int>? numbersList = null;
// numbersList?.Add(1); // This would not execute Add() if numbersList is null

// Null-conditional assignment evaluates the right side only if the left side is not null
Customer? customer = null; // Or some method that might return null
customer?.OrderCount = 5; // Only assigns if customer is not null, right side not evaluated if customer is null

// Before C# 14, you would need to write:
if (customer != null)
{
customer.OrderCount = 5; // Right side is always evaluated
}

// C# 14: Null-conditional assignment with compound operators
Dictionary<string, int>? counts = new();
counts?["key"] += 1; // Increments value only if counts is not null
// Works with +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= but not ++ or --
// This simplifies code that would otherwise require null checking:
// if (counts != null) counts["key"] += 1;

1.3 Null-Conditional Assignment (C# 14)

C# 14 introduces null-conditional assignment, allowing you to assign values to members of potentially null objects. The right side of the assignment is only evaluated if the left side is not null. [2]

// Basic null-conditional assignment with property access (?.)
Customer? customer = null; // Could be from a method that might return null
customer?.Name = "John"; // Only assigns if customer is not null

// The right side is not evaluated if the left side is null
customer?.Order = new Order(); // Constructor is not called if customer is null

// Null-conditional assignment with indexers (?[])
Dictionary<string, int>? counts = new();
counts?["key"] += 1; // Increments value only if counts is not null

// Works with multi-level null checking
Order? order = null; // Could be from a method that might return null
// Note: This assumes Items[0] is guaranteed to exist if Items is not null
order?.Items?.FirstOrDefault()?.Quantity = 5; // Only assigns if order, order.Items, and the first item are not null

// Works with all compound assignment operators
counts?["a"] += 1; // Addition
counts?["b"] -= 1; // Subtraction
counts?["c"] *= 2; // Multiplication
counts?["d"] /= 2; // Division
counts?["e"] %= 10; // Modulo
counts?["f"] &= 0xFF; // Bitwise AND
counts?["g"] |= 0x0F; // Bitwise OR
counts?["h"] ^= 0x55; // Bitwise XOR
counts?["i"] <<= 2; // Left shift
counts?["j"] >>= 1; // Right shift

// Note: ++ and -- operators are not supported
// counts?["key"]++; // Error

// Before C# 14, you would need to write:
if (counts != null) counts["key"] += 1;

New Escape Sequence (C# 13): \e for the ESCAPE character (Unicode U+001B). [1]

char escapeChar = '\e';
Console.WriteLine($"This is an {escapeChar}[31mError{escapeChar}[0m message."); // Example for ANSI escape code

1.4 Control Flow

If/Else:

int number = 10;
if (number > 0)
{
Console.WriteLine("Positive");
}
else if (number < 0)
{
Console.WriteLine("Negative");
}
else
{
Console.WriteLine("Zero");
}

Switch (including pattern matching):

object shape = new Circle(5); // Assuming Circle and Square classes exist
switch (shape)
{
case Circle c:
Console.WriteLine($"Circle with radius {c.Radius}");
break;
case Square s when s.Side > 10:
Console.WriteLine($"Large square with side {s.Side}");
break;
case null:
Console.WriteLine("Shape is null");
break;
default:
Console.WriteLine("Unknown shape");
break;
}

Loops:

  • for:

    for (int i = 0; i < 5; i++)
    {
    Console.WriteLine(i);
    }
  • while:

    int count = 0;
    while (count < 3)
    {
    Console.WriteLine(count);
    count++;
    }
  • do-while:

    int j = 0;
    do
    {
    Console.WriteLine(j);
    j++;
    } while (j < 3);
  • foreach:

    int[] numbers = { 1, 2, 3, 4, 5 };
    foreach (int num in numbers)
    {
    Console.WriteLine(num);
    }

New lock Object (C# 13): The lock statement can use System.Threading.Lock for improved thread synchronization. [1]

using System.Threading; // Required

// Traditional lock with object
private readonly object _legacyLock = new object();
public void LegacyCriticalSection()
{
lock (_legacyLock) // Uses Monitor.Enter/Exit
{
// Synchronized code
}
}

// New Lock type (.NET 9+)
private readonly Lock _modernLock = new Lock(); // .NET 9+
public void ModernMethod()
{
lock (_modernLock) // Uses Lock.EnterScope()
{
// Synchronized code with potentially better performance
}

// Alternatively, use the API directly:
using (_modernLock.EnterScope())
{
// Synchronized code
}
}

Best Practice: For new code targeting .NET 9+, consider using System.Threading.Lock for potentially better performance and diagnostics.

1.5 Methods and Parameters

Methods define reusable blocks of code.

public int Add(int a, int b)
{
return a + b;
}

// Method with optional parameters
public void Greet(string name, string greeting = "Hello")
{
Console.WriteLine($"{greeting}, {name}!");
}
Greet("Alice"); // Output: Hello, Alice!

// Expression-bodied members
public int Multiply(int a, int b) => a * b;

Optional Parameters in Lambdas (C# 12) & Modifiers on Simple Lambda Parameters (C# 14): Lambda expressions can have default parameter values (C# 12) (C# 12). C# 14 allows modifiers like ref, out, in, ref readonly, and scoped without explicit types (except params still needs an explicit type). [2]

// C# 12+: Optional parameters in lambdas
var greetLambda = (string name, string prefix = "Ms.") => $"{prefix} {name}";
Console.WriteLine(greetLambda("Smith")); // Output: Ms. Smith

// C# 14+: Parameter modifiers without explicit types
delegate bool TryParseDelegate<T>(string text, out T result);
TryParseDelegate<int> parser = (text, out result) => int.TryParse(text, out result); // 'result' type inferred
// Previously required: (string text, out int result) => int.TryParse(text, out result)

// More examples of modifiers without types:
delegate int RefFunc(ref int x);
RefFunc doubleIt = (ref x) => { x *= 2; return x; }; // 'ref' modifier

delegate void InAction<T>(in T val);
InAction<int> readValue = (in val) => Console.WriteLine(val); // 'in' modifier

delegate void SpanAction<T>(Span<T> span);
SpanAction<byte> processData = (scoped span) => span[0] = 1; // 'scoped' modifier

// C# 14 breaking change: 'scoped' is always treated as a modifier in lambdas,
// even if there's a type named 'scoped'. Use '@scoped' to refer to a type named 'scoped'.
ref struct @scoped { } // Type named 'scoped'
var lambda = (scoped @scoped s) => { }; // 'scoped' is modifier, '@scoped' is type

params Collections (C# 13): params can be used with collection types like IEnumerable<T>, Span<T>, ReadOnlySpan<T>, not just arrays. [1]

public void PrintItems<T>(params IEnumerable<T> items)
{
foreach (var item in items) Console.Write($"{item} ");
Console.WriteLine();
}
PrintItems("Hello", "World", "!"); // Calls with individual string arguments
PrintItems(new List<string> { "One", "Two" }); // Calls with a List

public void ProcessSpan(params ReadOnlySpan<char> text)
{
Console.WriteLine(new string(text));
}
ProcessSpan('a', 'b', 'c'); // abc

ref readonly parameters (C# 12) & ref and unsafe in iterators and async methods (C# 13): ref readonly improves performance for large structs. C# 13 allows ref locals and unsafe contexts in iterators and async methods (with restrictions, e.g., ref locals can't cross await or yield return). [1]

// C# 12
public void PrintLargeStruct(ref readonly LargeStructDetails details)
{
Console.WriteLine(details.Id); // Cannot modify 'details'
}

// C# 13: Iterator method with ref locals and unsafe code
public IEnumerable<int> GetNumbers()
{
int value = 42;
ref int localRef = ref value; // Allowed in C# 13+

unsafe
{
int* ptr = &value; // Unsafe code allowed in C# 13+
*ptr = 100;
}

yield return localRef; // 100

// Important: ref locals can't cross yield boundaries
// After this point, localRef can't be used safely
yield return 200;
}

// C# 13: Async method with ref locals
public async Task<int> GetValueAsync()
{
int value = 42;
ref int localRef = ref value; // Allowed in C# 13+

localRef = 100;

// Important: ref locals can't cross await boundaries
await Task.Delay(100);
// After this point, localRef can't be used safely

return value; // 100
}

1.6 Error Handling (try-catch-finally)

Handles exceptions that may occur during program execution.

try
{
int result = 10 / int.Parse("0");
}
catch (DivideByZeroException ex)
{
Console.WriteLine("Cannot divide by zero.");
}
catch (FormatException ex)
{
Console.WriteLine("Invalid number format.");
}
finally
{
Console.WriteLine("Cleanup code that always runs.");
}

1.7 Namespaces and using Directives

Namespaces organize code. using imports types.

C# 12: using alias any type: Aliases for any type, including tuple types. [1]

using System;
using System.Collections.Generic;
using Point = (int X, int Y); // C# 12
using StringList = System.Collections.Generic.List<string>; // C# 12

namespace MyProject
{
public class MyClass
{
public Point GetOrigin() => (0, 0);
public void ProcessNames(StringList names) { /* ... */ }
}
}

1.8 nameof with Unbound Generic Types (C# 14)

C# 14 allows nameof to refer to unbound generic types, making it easier to get the base name of generic types. [2]

// C# 14: nameof with unbound generic types
string listTypeName = nameof(List<>); // "List"
string dictionaryTypeName = nameof(Dictionary<,>); // "Dictionary"

// Before C# 14, you had to use a closed generic type:
string oldListTypeName = nameof(List<int>); // "List"

// This is useful for error messages, logging, and reflection:
void ValidateGenericType<T>() where T : IComparable<T>
{
Console.WriteLine($"Validating {nameof(T)} against {nameof(IComparable<>)} constraint");
}

// Also works with nested generic types:
string nestedTypeName = nameof(Dictionary<,>.KeyCollection<>); // "KeyCollection"

1.9 Method Group Natural Type Improvements (C# 13)

C# 13 optimizes overload resolution for method groups by pruning inapplicable methods at each scope. [1]

// Example of method group used as delegate:
void ProcessItems<T>(Action<T> processor, T item) => processor(item);

// These methods exist in scope:
void Process(int x) { Console.WriteLine($"Processing int: {x}"); }
void Process<T>(T x) where T : struct { Console.WriteLine($"Processing struct: {x}"); } // Generic with constraint
void Process(string s, bool flag = false) { Console.WriteLine($"Processing string: {s}"); } // With optional parameter

// In C# 13+:
ProcessItems(Process, 42); // Resolves to Process(int) directly
// In C# 12, all methods named Process would be considered,
// even those that couldn't possibly match (like the string overload).

// C# 13 prunes inapplicable methods at each scope:
// 1. First, it eliminates Process(string, bool) because it doesn't match the Action<int> signature
// 2. Then, it considers Process(int) and Process<T>(T) where T : struct
// 3. Since both are applicable, it chooses Process(int) as the non-generic method is preferred

1.10 Overload Resolution Priority (C# 13)

The OverloadResolutionPriorityAttribute allows library authors to designate one overload as better than another. [1]

using System.Runtime.CompilerServices;

public class StringUtility
{
// Legacy method (lower priority)
[OverloadResolutionPriority(1)]
public static string Join(string[] values)
{
return string.Join(",", values);
}

// New, more performant method (higher priority)
[OverloadResolutionPriority(2)]
public static string Join(ReadOnlySpan<string> values)
{
// More efficient implementation
return string.Join(",", values);
}
}

// When recompiled with C# 13+, this will use the ReadOnlySpan<string> overload
// even though both are applicable, because it has higher priority
string result = StringUtility.Join(new[] { "a", "b", "c" });

2. Data Structures

2.1 Arrays

Fixed-size collections of elements of the same type.

Single-dimensional: int[] numbers = { 1, 2, 3 }; Multi-dimensional: int[,] matrix = { { 1, 2 }, { 3, 4 } }; Jagged: int[][] jagged = new int[2][]; jagged[0] = new int[] {1,2}; jagged[1] = new int[] {3,4,5};

C# 12: Inline Arrays: Fixed-size arrays embedded in a struct (for performance-critical scenarios). [1]

[System.Runtime.CompilerServices.InlineArray(10)]
public struct MyBuffer
{
private int _element0; // Compiler generates _element1 through _element9

// Proper indexer implementation for access
public int this[int index]
{
get
{
if ((uint)index >= 10) // Unsigned check for range optimization
throw new IndexOutOfRangeException();

// Use Unsafe.Add to access any element in the inline array
return System.Runtime.CompilerServices.Unsafe.Add(ref _element0, index);
}
set
{
if ((uint)index >= 10)
throw new IndexOutOfRangeException();

// Use Unsafe.Add to access any element in the inline array
System.Runtime.CompilerServices.Unsafe.Add(ref _element0, index) = value;
}
}
}

// Usage example:
// var buffer = new MyBuffer();
// buffer[0] = 42;
// int value = buffer[0]; // 42

Note: InlineArray attribute cannot be applied to a record struct (C# 13 breaking change). [4]

C# 12: Collection Expressions: Concise syntax for creating common collection values. [1]

// Creates an array:
int[] arr1 = [1, 2, 3];

// Creates a List<int>:
List<int> list1 = [4, 5, 6];

// Creates a Span<int>:
Span<int> span1 = ['a', 'b', 'c']; // char literals are implicitly convertible to int

// Spread operator ..
int[] row1 = [1, 2, 3];
int[] row2 = [10, 11, 12, 13];
int[] combined = [..row1, ..row2, 14, 15]; // [1, 2, 3, 10, 11, 12, 13, 14, 15]

Note: C# 13 introduced changes to collection expression overload resolution. [4] In C# 14, overload resolution over Span<T> in most cases, and exact element type matches are preferred over all else.

First-Class Span<T> and ReadOnlySpan<T> Support (C# 14): C# 14 introduces first-class support for these types with new implicit conversions and improved overload resolution. [2]

// 1. New implicit conversions:
void ProcessData(ReadOnlySpan<byte> data) { /* ... */ }
byte[] myArray = { 1, 2, 3 };
ProcessData(myArray); // Implicit conversion from byte[] to ReadOnlySpan<byte>

// C# 14 adds these implicit conversions:
// - T[] to ReadOnlySpan<T>
// - T[] to Span<T> (when array is not readonly)
// - Span<T> to ReadOnlySpan<T>

// 2. Improved overload resolution:
void Process<T>(IEnumerable<T> items) { /* ... */ }
void Process<T>(Span<T> items) { /* ... */ }
void Process<T>(ReadOnlySpan<T> items) { /* ... */ } // This overload is preferred for arrays

string[] strings = ["hello"];
object[] objects = strings; // Array covariance
Process(objects); // In C# 13: Calls Span<T> overload, crashes at runtime with ArrayTypeMismatchException
// In C# 14: Calls ReadOnlySpan<T> overload, works correctly

// 3. Span types as extension method receivers:
public static class SpanExtensions
{
public static int CountPositive(this Span<int> span)
{
int count = 0;
foreach (int i in span) if (i > 0) count++;
return count;
}
}

int[] numbers = [1, -2, 3, -4, 5];
int positiveCount = numbers.CountPositive(); // Array implicitly converted to Span<int>

// 4. Better generic type inference:
T FindFirst<T>(ReadOnlySpan<T> items, Func<T, bool> predicate)
{
foreach (var item in items)
if (predicate(item)) return item;
throw new InvalidOperationException("No matching item found");
}

string[] names = ["Alice", "Bob", "Charlie"];
string result = FindFirst(names, name => name.StartsWith("B")); // "Bob"
// Type inference works with the implicit conversion

// Breaking changes with Span<T> in C# 14:

// 1. LINQ and arrays:
int[] numbersArray = [1, 2, 3];
// numbersArray.Reverse(); // In C# 13: Calls Enumerable.Reverse (returns new sequence)
// In C# 14: Calls MemoryExtensions.Reverse (in-place, returns void)

// Workarounds:
var reversed1 = System.Linq.Enumerable.Reverse(numbersArray); // Returns IEnumerable<int>
var reversed2 = numbersArray.AsEnumerable().Reverse(); // Returns IEnumerable<int>

// 2. Array variance and Span<T> (continued):
// When using arrays with variance, ReadOnlySpan<T> is preferred in C# 14
void Process<T>(IEnumerable<T> items) { /* ... */ }
void Process<T>(Span<T> items) { /* ... */ }
void Process<T>(ReadOnlySpan<T> items) { /* ... */ }

// Process(objects); // In C# 13: Calls Span<T> overload, crashes at runtime with ArrayTypeMismatchException
// In C# 14: Calls ReadOnlySpan<T> overload, works correctly

// 3. Overload resolution changes:
// - ReadOnlySpan<T> is preferred over Span<T> in most cases
// - Exact element type matches are preferred over all else
// - These changes help avoid runtime errors with array variance

Implicit Index Access ^ in Collection Initializers (C# 13): Index from end syntax [^index] can be used in collection initializers. [1]

// Array initialization with index from end
int[] items = new int[5];
items[^1] = 10; // Last element
items[^2] = 20; // Second-to-last element
// items is now {0, 0, 0, 20, 10}

// Can also be used in collection initializers
var dict = new Dictionary<Index, string> { [^1] = "last", [^2] = "second-to-last" };

2.2 Collections (System.Collections.Generic)

Dynamic collections.

  • List<T>: Dynamically sized list. List<string> names = ["Alice", "Bob"];
  • Dictionary<TKey, TValue>: Key-value pairs. Dictionary<string, int> ages = new() { {"Alice", 30}, {"Bob", 25} };
  • HashSet<T>: Unordered unique elements. HashSet<int> numbers = [1, 2, 3, 2]; (contains 1, 2, 3)
  • Queue<T>: FIFO. Queue<string> tasks = new(["A", "B"]); var t = tasks.Dequeue(); // "A"
  • Stack<T>: LIFO. Stack<string> history = new(["Page1", "Page2"]); var p = history.Pop(); // "Page2"

2.3 LINQ (Language Integrated Query)

Powerful query capabilities.

Query Syntax:

List<int> numbers = [1, 2, 3, 4, 5, 6];
var evenNumbers = from num in numbers
where num % 2 == 0
select num;

Method Syntax:

var oddNumbers = numbers.Where(num => num % 2 != 0).ToList();

// C# 14: Span<T> and ReadOnlySpan<T> conversions affect LINQ
int[] array = [1, 2, 3, 4, 5];
// In C# 13: array.Reverse() calls Enumerable.Reverse (returns new sequence)
// In C# 14: array.Reverse() calls MemoryExtensions.Reverse (in-place, returns void)
// Workaround: use explicit method call
var reversed = Enumerable.Reverse(array); // Explicitly call Enumerable.Reverse

// C# 14: More partial members (constructors and events)
// See the "Partial Constructors and Events" section for details

3. Object-Oriented Programming (OOP) Principles

3.1 Classes and Objects

Class is a blueprint; object is an instance.

C# 12: Primary Constructors for Classes/Structs: Concise constructor syntax. [1]

public class Student(string id, string name)
{
public string Id { get; } = id;
public string Name { get; set; } = name; // Can be mutable

public void DisplayInfo()
{
// Parameters 'id' and 'name' are in scope for initialization
// and can be captured by methods/properties.
Console.WriteLine($"Student: {id}, Name: {Name}");
}
}
var student = new Student("S101", "Maria");
student.DisplayInfo(); // Student: S101, Name: Maria

3.2 Encapsulation

Bundling data and methods. Access modifiers (public, private, protected, internal, protected internal, private protected).

Properties: Controlled access to fields.

public class Account
{
private decimal _balance;
public decimal Balance
{
get => _balance;
private set => _balance = value; // Can only be set within the class
}

// C# 14: 'field' keyword for auto-property backing field access
public string Status
{
get => field; // Accesses the compiler-synthesized backing field for Status
set => field = string.IsNullOrWhiteSpace(value) ? "Default" : value;
}

// Method to deposit money
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Deposit amount must be positive");
_balance += amount;
}
}

### 3.3 The `field` Keyword (C# 14)
The `field` keyword provides direct access to the compiler-synthesized backing field in property accessors. It was a preview feature in C# 13 and is fully supported in C# 14. [2]

```csharp
public class Person
{
// Traditional approach with explicit backing field
private string _name = "";
public string Name
{
get => _name;
set => _name = value?.Trim() ?? "";
}

// C# 14 approach with 'field' keyword
public string Email
{
get => field; // Access the backing field directly
set => field = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Email cannot be empty")
: value.ToLowerInvariant();
}

// Property with custom getter but auto-implemented setter
public int Age
{
get => field < 0 ? 0 : field; // Ensure age is never negative
set; // Auto-implemented setter still uses the same backing field
}

// Warning CS9258 or Error CS9272 if a member named 'field' exists in scope
private string field = "Example"; // Member named 'field'

public string Description
{
get => @field; // Use '@field' to refer to the member, not the keyword
set => this.field = value; // Use 'this.field' to refer to the member
}
}

// Breaking change in C# 14: The 'field' keyword is now fully supported
// and will generate warnings/errors if it conflicts with existing members
public class Account
{
private decimal _balance;
public decimal Balance
{
get => _balance;
private set => _balance = value;
}

public Account(decimal initialBalance) => _balance = initialBalance;
public void Deposit(decimal amount) { if (amount > 0) Balance += amount; }
}

3.4 Inheritance

Derived classes inherit from base classes.

public class Animal
{
public virtual void MakeSound() => Console.WriteLine("Generic sound");
}

public class Cat : Animal
{
public override void MakeSound() => Console.WriteLine("Meow");
}

3.5 Polymorphism

Objects treated as instances of a common base class/interface.

  • Method Overloading: Same method name, different parameters.
  • Method Overriding: (See MakeSound() in Inheritance).

3.6 Abstraction

Hiding implementation, showing essential features.

  • Abstract Classes: Cannot be instantiated; can have abstract methods.

    public abstract class Shape
    {
    public abstract double GetArea(); // Must be implemented
    public void Display() => Console.WriteLine("Displaying shape");
    }
    public class Square : Shape // Assume constructor and side property
    {
    public double Side {get; }
    public Square(double side) => Side = side;
    public override double GetArea() => Side * Side;
    }
  • Interfaces: Define a contract.

    public interface ILoggable
    {
    void Log(string message);
    }
    public class ConsoleLogger : ILoggable
    {
    public void Log(string message) => Console.WriteLine(message);
    }

ref struct Interfaces and allows ref struct (C# 13): C# 13 introduces two related features:

  1. ref struct types can implement interfaces [1]
  2. Generic type parameters can use the allows ref struct constraint [1]
// 1. ref struct implementing an interface
public interface IMySpanProcessor
{
void Process(Span<int> data);
}

public ref struct MySpanProcessorImpl : IMySpanProcessor // C# 13
{
public void Process(Span<int> data)
{
for(int i = 0; i < data.Length; i++) data[i] *= 2;
}
}

// 2. Generic class with 'allows ref struct' constraint
public class GenericProcessor<T> where T : allows ref struct
{
public void ProcessScoped(scoped T value)
{
// Can safely use T as a ref struct
Console.WriteLine($"Processing {typeof(T).Name}");
}
}

// Use the generic constraint to work with ref struct implementations
public void UseProcessor<T>(T processor, Span<int> data)
where T : IMySpanProcessor, allows ref struct // Both constraints
{
processor.Process(data);
}

// This won't work - ref structs can't be converted to interface types:
// IMySpanProcessor processor = new MySpanProcessorImpl(); // Error!

// But this works:
var processor = new MySpanProcessorImpl();
UseProcessor(processor, new int[] { 1, 2, 3 });

// And this works too:
var genericProc = new GenericProcessor<Span<int>>();
genericProc.ProcessScoped(new int[] { 1, 2, 3 });

3.7 Structs and Records

Structs: Value types for small, lightweight objects.

public struct Point2D(double x, double y) // C# 12 primary constructor for struct
{
public double X { get; } = x;
public double Y { get; } = y;
}

Records (Record Class / Record Struct): Concise syntax for data-centric types with immutability and value-based equality.

// Record class (reference type)
public record Person(string FirstName, string LastName);
var p1 = new Person("Sam", "Hill");
var p2 = p1 with { LastName = "Valley" }; // Non-destructive mutation

// Record struct (value type, C# 10+)
public readonly record struct Color(byte R, byte G, byte B);
// Primary constructors (C# 12+) are natural for records and record structs.

// C# 14 breaking change: record types cannot have pointer fields
// This was always in the specification but is now properly enforced
unsafe
{
// This will not compile in C# 14:
// record struct UnsafeRecord(int* Ptr); // Error CS8908 in C# 14

// Even with custom Equals implementation:
// record struct UnsafeRecordWithEquals(int* Ptr)
// {
// public bool Equals(UnsafeRecordWithEquals other) => true;
// }
}

Note: record and record struct types cannot define pointer type members in C# 14, even with custom Equals implementations (breaking change). [3]

Partial Members (Expanded in C# 13 & C# 14)

Allows defining a class, struct, interface, or method in multiple files.

  • C# 13: Partial Properties and Indexers. [1]
  • C# 14: Partial Instance Constructors and Events. [2]
// File1.cs
public partial class MyData
{
// C# 13: Declaring partial property
public partial string? Description { get; set; }

// C# 14: Declaring partial constructor
public partial MyData(string initialValue);

// C# 14: Declaring partial event
public partial event EventHandler? DataChanged;
}

// File2.cs
public partial class MyData
{
private string? _backingDescription;
// C# 13: Implementing partial property
public partial string? Description
{
get => _backingDescription;
set
{
_backingDescription = value?.Trim();
DataChanged?.Invoke(this, EventArgs.Empty);
}
}

// C# 14: Implementing partial constructor
public partial MyData(string initialValue)
{
this.Description = initialValue;
Console.WriteLine("MyData partial constructor initialized.");
}

// C# 14: Implementing partial event
private EventHandler? _dataChangedHandler;
public partial event EventHandler? DataChanged
{
add => _dataChangedHandler += value;
remove => _dataChangedHandler -= value;
}
}

Breaking Change: Partial interface properties and events are now implicitly virtual and public in C# 14. [3] This matches the behavior of non-partial interface members but may change how your code behaves.

// Before C# 14:
partial interface I
{
partial int P { get; } // Was implicitly private
partial int P => 1; // Was implicitly non-virtual
}

// In C# 14:
partial interface I
{
partial int P { get; } // Now implicitly public
partial int P => 1; // Now implicitly virtual
}

// To maintain previous behavior, explicitly specify modifiers:
partial interface I
{
private partial int P { get; } // Explicitly private
sealed partial int P => 1; // Explicitly non-virtual
}

Extension Members (C# 14)

C# 14 introduces a powerful new syntax for defining extension members beyond just methods, including properties, indexers, and static extensions. This feature significantly expands the capabilities of extension functionality. [2]

Extension members allow you to:

  • Add properties, indexers, and methods to existing types without modifying them
  • Define static extension members that appear as static members of the extended type
  • Group related extension members in a single block for better organization

Breaking Change: In C# 14, extension is a contextual keyword and cannot be used as a type name, type parameter name, or alias. Use @extension to escape it when needed as an identifier. [3]

// These will not compile in C# 14:
using extension = System.String; // Error: alias may not be named "extension"
class extension { } // Error: type may not be named "extension"
class C<extension> { } // Error: type parameter may not be named "extension"

// Workaround: Use @ to escape the identifier
using @extension = System.String; // OK
class @extension { } // OK
class C<@extension> { } // OK

The new extension syntax uses blocks to group related extension members:

public static class StringExtensions
{
// C# 14: Extension block for instance members
extension(string str) // Target type is string, parameter name is str
{
// Extension method
public bool IsNullOrEmpty() => string.IsNullOrEmpty(str);

// Extension method that acts like a property
public string? Truncate(int maxLength) =>
str.Length <= maxLength ? str : str.Substring(0, maxLength) + "...";

// Extension property
public char FirstChar => str.Length > 0 ? str[0] : throw new InvalidOperationException();

// Extension indexer
public char this[Index index] => str[index];
}

// C# 14: Extension block for static members (receiver type only)
extension(string) // Target type is string, no parameter name (static context)
{
// Static extension property
public static string DefaultGreeting => "Hello, World!";

// Static extension method
public static string Combine(string first, string second) => $"{first} {second}";
}
}

// Usage examples:

// 1. Instance extension methods and properties
string text = "Hello, World!";
Console.WriteLine(text.IsNullOrEmpty()); // False
Console.WriteLine(text.Truncate(5)); // "Hello..."
Console.WriteLine(text.FirstChar); // 'H'
Console.WriteLine(text[^1]); // '!'

// Extension methods can be called on null instances (unlike instance methods)
string? nullText = null;
Console.WriteLine(nullText.IsNullOrEmpty()); // True - works even though nullText is null

// 2. Static extension members (called as if they were static members of the type)
Console.WriteLine(string.DefaultGreeting); // "Hello, World!"
Console.WriteLine(string.Combine("Hello", "World")); // "Hello World"

// Before C# 14, you would need to write:
// Console.WriteLine(StringExtensions.DefaultGreeting);
// Console.WriteLine(StringExtensions.Combine("Hello", "World"));

// Extension blocks with generic type parameters:
public static class EnumerableExtensions
{
// Generic extension block for instance members
extension<T>(IEnumerable<T> items)
{
public bool IsEmpty => !items.Any();
public T FirstOrDefault(T defaultValue) => items.Any() ? items.First() : defaultValue;

// Extension method with multiple parameters
public IEnumerable<T> TakeIfAny(int count) =>
IsEmpty ? Enumerable.Empty<T>() : items.Take(count);
}

// Generic extension block for static members
extension<T>(IEnumerable<T>)
{
// Static extension method
public static IEnumerable<T> CreateSingleton(T item) => new[] { item };

// Static extension property
public static IEnumerable<T> Empty => Enumerable.Empty<T>();
}
}

// 3. Usage with generic extension members:
List<int> numbers = [1, 2, 3];
Console.WriteLine(numbers.IsEmpty); // False
Console.WriteLine(numbers.FirstOrDefault(0)); // 1
var firstTwo = numbers.TakeIfAny(2).ToList(); // [1, 2]

// Empty list example
List<int> emptyList = [];
Console.WriteLine(emptyList.IsEmpty); // True
Console.WriteLine(emptyList.FirstOrDefault(42)); // 42 (default value)
var noItems = emptyList.TakeIfAny(5).ToList(); // [] (empty)

// 4. Static extension members with generic types:
Console.WriteLine(IEnumerable<int>.Empty.Count()); // 0
var singleton = IEnumerable<string>.CreateSingleton("Hello"); // ["Hello"]

// This is a major improvement over traditional extension methods:
// Before C# 14:
// Console.WriteLine(EnumerableExtensions.Empty<int>().Count());
// var singleton = EnumerableExtensions.CreateSingleton("Hello");

4. Additional C# 13 and C# 14 Features and Breaking Changes

This section highlights unique features and breaking changes not covered in detail elsewhere in this cheatsheet.

4.1 Enumerator Behavior Changes (C# 14)

In C# 14, calling MoveNext() on a disposed enumerator properly returns false without executing any more user code. [3]

// Before C# 14:
var enumerator = GetEnumerator();
Console.Write(enumerator.MoveNext()); // True
Console.Write(enumerator.Current); // 1
enumerator.Dispose();
Console.Write(enumerator.MoveNext()); // True, and would execute more code

// In C# 14:
var enumerator = GetEnumerator();
Console.Write(enumerator.MoveNext()); // True
Console.Write(enumerator.Current); // 1
enumerator.Dispose();
Console.Write(enumerator.MoveNext()); // False, no more code executed

static IEnumerator<int> GetEnumerator()
{
yield return 1;
Console.Write("This won't execute after disposal in C# 14");
yield return 2;
}

4.2 Obsolete Methods in await foreach (C# 14)

C# 14 changes how await foreach handles IAsyncEnumerable<T> implementations with obsolete methods. [3]

// If AsyncEnumerator.DisposeAsync() is marked [Obsolete],
// C# 14 will report the obsolete warning when using await foreach
await foreach (var item in new AsyncCollection()) { }

// Example with obsolete DisposeAsync method:
class AsyncCollection
{
public AsyncEnumerator GetAsyncEnumerator(CancellationToken token = default) => new();

public sealed class AsyncEnumerator : IAsyncDisposable
{
public int Current => 42;
public ValueTask<bool> MoveNextAsync() => new(true);

[Obsolete("Use the new DisposeSafelyAsync method instead")]
public ValueTask DisposeAsync() => new(); // C# 14 will report this as obsolete
}
}

5. References

[1] What's new in C# 13
[2] What's new in C# 14
[3] C# compiler breaking changes since C# 13
[4] C# compiler breaking changes since C# 12

This cheatsheet covers many fundamental and new aspects of C# up to version 14. For more in-depth information, always refer to the official Microsoft C# documentation.