- A brief walk through the .NET ecosystem
- Modern C# v6‐13 features
- 200+ interview Q&As that hiring managers ask
- 50+ real‐world design patterns in C#
- Clean code development in C# course
- ASP.NET Core auth & middleware best practices
- Bonus: A complete C# Cheat Sheet
Get the .NET Ultimate Bundle 🚀
If you want to learn more about C# and .NET technologies, be sure to subscribe to my newsletter .
This comprehensive C# cheat sheet serves as a quick reference guide for C# developers at all skill levels. It covers the core language features, modern patterns, and best practices as of 2025.
The cheat sheet organizes fundamental concepts into more advanced topics,** making it useful for learning and reference.
C# has evolved significantly since its inception, with regular updates introducing powerful new features while maintaining backward compatibility.
This guide incorporates the latest language enhancements through C# 13 and beyond, as well as code organization, and development approaches that have become standard in the .NET ecosystem.
🔖 Feel free to bookmark this page and refer to it whenever you need to refresh your knowledge of C# language features or modern development techniques.
The image below show an overview of the C# language features and concepts covered in this cheat sheet:
If you find this repository helpful, consider supporting me on Patreon:
If you like or are using this project to learn or start your solution, please give it a star. Thanks!
- Comments
- Strings
- Basic types and literals
- Statements
- Methods and functions
- Delegates and events
- Data types
- Generics
- Classes
- Collections
- Pattern matching
- Exceptions
- Asynchronous programming
- Code organization
Comments in C# provide ways to document your code, explain complex logic, and temporarily disable code during development. C# supports three types of comments: single-line, multi-line, and XML documentation comments. Good commenting practices are essential for code maintainability, especially in team environments.
// This is a single-line comment /* This is a multi-line comment */ /// <summary> /// XML documentation comment used to generate documentation /// </summary> public void DocumentedMethod() { }
XML documentation comments can include various tags to document parameters, return values, exceptions, etc.
/// <summary> /// Adds two integers and returns the result /// </summary> /// <param name="a">First integer</param> /// <param name="b">Second integer</param> /// <returns>The sum of the two integers</returns> /// <exception cref="OverflowException">Thrown when the sum is too large</exception> public int Add(int a, int b) => a + b;
Additional resources:
Strings in C# are immutable sequences of Unicode characters represented by the string
type (an alias for System.String
). C# offers a rich set of string manipulation features, from basic concatenation to advanced interpolation and raw string literals. The language has evolved significantly to make string handling more intuitive and efficient.
// Basic string creation string greeting = "Hello"; string name = "World"; string message = greeting + " " + name; // "Hello World" // String interpolation (C# 6.0+) string interpolated = $"{greeting} {name}!"; // "Hello World!" // Verbatim strings (preserves formatting and ignores escape sequences except "") string path = @"C:\Users\UserName\Documents"; string multiline = @"This is a multi-line string that preserves formatting"; // Verbatim string interpolation string verbatimInterpolated = $@"User {name} Path: {path}"; // Raw string literals (C# 11+) - no escape sequences, preserves formatting string json = """ { "name": "John Doe", "age": 30, "isActive": true } """; // Raw string interpolation (C# 11+) string name = "Jane"; string rawInterpolated = $""" { "name": "{{name}}", "created": "{{DateTime.Now}}" } """; // Common string methods string text = "Hello, World!"; bool contains = text.Contains("World"); // true string upper = text.ToUpper(); // "HELLO, WORLD!" string lower = text.ToLower(); // "hello, world!" string replaced = text.Replace("Hello", "Hi"); // "Hi, World!" string trimmed = " text ".Trim(); // "text" string[] split = text.Split(','); // ["Hello", " World!"] int length = text.Length; // 13 // String comparison bool equals = string.Equals("abc", "ABC", StringComparison.OrdinalIgnoreCase); // true int comparison = string.Compare("abc", "ABC", StringComparison.Ordinal); // not equal
For better performance with repeated string concatenation (as String is immutable type), use StringBuilder
:
using System.Text; StringBuilder sb = new StringBuilder(); for (int i = 0; i < 100; i++) { sb.Append($"Item {i}, "); } string result = sb.ToString();
Additional resources:
- String class (Microsoft Docs)
- String interpolation (Microsoft Docs)
- Raw String literals (Microsoft Docs)
- String performance best practices
C# is a strongly-typed language with a comprehensive type system that forms the foundation of all C# programs. Understanding these basic types is essential for writing efficient and type-safe code.
C# types are categorized as value types (stored on the stack) and reference types (stored on the heap), each with different memory and performance characteristics.
// Integer types byte byteValue = 255; // 8-bit unsigned integer (0 to 255) sbyte sbyteValue = -128; // 8-bit signed integer (-128 to 127) short shortValue = -32768; // 16-bit signed integer (-32,768 to 32,767) ushort ushortValue = 65535; // 16-bit unsigned integer (0 to 65,535) int intValue = -2147483648; // 32-bit signed integer (-2,147,483,648 to 2,147,483,647) uint uintValue = 4294967295; // 32-bit unsigned integer (0 to 4,294,967,295) long longValue = -9223372036854775808; // 64-bit signed integer (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807) ulong ulongValue = 18446744073709551615; // 64-bit unsigned integer (0 to 18,446,744,073,709,551,615) // Integer literals int decimalLiteral = 42; // Decimal (base 10) int hexLiteral = 0x2A; // Hexadecimal (base 16) int binaryLiteral = 0b101010; // Binary (base 2) int withSeparator = 1_000_000; // Digit separator for readability // Floating point types float floatValue = 3.14f; // 32-bit floating-point (7 significant digits precision) double doubleValue = 3.14159265359; // 64-bit floating-point (15-16 significant digits precision) decimal decimalValue = 3.14159265359m; // 128-bit high-precision decimal (28-29 significant digits) // Boolean type bool trueValue = true; bool falseValue = false; // Character type char charValue = 'A'; // Unicode character char unicodeChar = '\u0041'; // Unicode escape sequence for 'A' char escapeChar = '\n'; // Newline escape sequence // DateTime and TimeSpan DateTime now = DateTime.Now; DateTime utcNow = DateTime.UtcNow; DateOnly today = DateOnly.FromDateTime(DateTime.Today); // Date without time (C# 10+) TimeOnly noon = new TimeOnly(12, 0, 0); // Time without date (C# 10+) DateTime specific = new DateTime(2023, 1, 1); TimeSpan oneHour = TimeSpan.FromHours(1); TimeSpan duration = TimeSpan.FromMinutes(90); // Nullable types (can be null) int? nullableInt = null; bool? nullableBool = null; // Default values int defaultInt = default; // 0 bool defaultBool = default; // false string defaultString = default; // null DateTime defaultDateTime = default; // 0001年01月01日 00:00:00 // Numeric type aliases (C# 12+) using intptr = nint; // Native-sized integer using uintptr = nuint; // Unsigned native-sized integer using index = System.Index; // Type alias for Index
Type inference with var
(compile-time determined):
var inferredInt = 42; // Compiler infers int var inferredString = "Hello"; // Compiler infers string var inferredList = new List<int>(); // Compiler infers List<int>
Constants and readonly:
// Compile-time constants (must be primitive types or string) const double Pi = 3.14159; const string AppName = "MyApp"; // Runtime constants readonly DateTime StartTime = DateTime.Now; // Static readonly - initialized only once at runtime public static readonly HttpClient SharedClient = new HttpClient(); // Init-only setter - can only be set during initialization public string Id { get; init; } = Guid.NewGuid().ToString(); // Read-only fields/properties with field/property initializers public required string Name { get; init; }
Additional resources:
- Built-in types (Microsoft Docs)
- Value types (Microsoft Docs)
- Reference types (Microsoft Docs)
- Constants (Microsoft Docs)
- DateOnly and timeOnly types (Microsoft Docs)
Statements are the building blocks of C# code execution, controlling the flow of your program and dictating how operations are carried out. Understanding these core language constructs is essential for effective C# programming.
Control flow statements allow you to make decisions and execute different code paths based on conditions.
If-else statements execute different code blocks based on boolean conditions. They form the foundation of decision-making in C#.
// Basic if statement if (condition) { // Code executed if condition is true } // If-else if (temperature > 30) { Console.WriteLine("It's hot outside"); } else { Console.WriteLine("It's not too hot"); } // If-else if-else chain if (temperature > 30) { Console.WriteLine("It's hot outside"); } else if (temperature > 20) { Console.WriteLine("It's warm outside"); } else if (temperature > 10) { Console.WriteLine("It's cool outside"); } else { Console.WriteLine("It's cold outside"); } // Conditional (ternary) operator - shorthand for simple if-else string message = age >= 18 ? "Adult" : "Minor"; // Null-coalescing operator (??) - returns the left operand if it's not null, otherwise the right string name = userName ?? "Anonymous"; // Null-conditional operator (?.) - safely accesses members of potentially null objects int? length = customer?.Name?.Length; // Null-coalescing assignment (??=) - C# 8.0+ // Assigns the right operand only if the left operand is null userName ??= "Anonymous";
Switch statements provide a way to handle multiple possible conditions for a single value. Modern C# also offers powerful switch expressions.
// Traditional switch statement switch (dayOfWeek) { case DayOfWeek.Monday: Console.WriteLine("Start of work week"); break; case DayOfWeek.Friday: Console.WriteLine("End of work week"); break; case DayOfWeek.Saturday: case DayOfWeek.Sunday: Console.WriteLine("Weekend"); break; default: Console.WriteLine("Midweek"); break; } // Switch expression (C# 8.0+) string GetDayType(DayOfWeek day) => day switch { DayOfWeek.Monday => "Start of work week", DayOfWeek.Friday => "End of work week", DayOfWeek.Saturday or DayOfWeek.Sunday => "Weekend", _ => "Midweek" // Default case }; // Switch expression with pattern matching string GetShapeDescription(object shape) => shape switch { Circle c when c.Radius > 10 => "Large circle", Circle _ => "Circle", Rectangle { Width: 0 } => "Line", Rectangle { Length: var l, Width: var w } when l == w => "Square", Rectangle _ => "Rectangle", null => "No shape", _ => "Unknown shape" };
Loops allow you to execute a block of code repeatedly until a condition is met.
For loops are ideal when you know the number of iterations in advance.
// Basic for loop for (int i = 0; i < 10; i++) { Console.WriteLine($"Iteration {i}"); } // Multiple loop variables for (int i = 0, j = 10; i < j; i++, j--) { Console.WriteLine($"i = {i}, j = {j}"); } // Nested for loops for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { Console.WriteLine($"Position [{i},{j}]"); } } // Breaking out of a loop for (int i = 0; i < 100; i++) { if (i > 10) break; // Exits the loop Console.WriteLine(i); } // Skipping an iteration for (int i = 0; i < 10; i++) { if (i % 2 == 0) continue; // Skips to the next iteration Console.WriteLine($"Odd number: {i}"); }
Foreach loops are designed for iterating through collections and are simpler to use than for loops when the iteration count isn't important.
// Basic foreach loop foreach (string name in names) { Console.WriteLine(name); } // Using index with foreach (C# 9.0+) foreach (string name in names.Select((value, index) => new { value, index })) { Console.WriteLine($"{name.index}: {name.value}"); } // Iterating through key-value pairs foreach (KeyValuePair<string, int> pair in dictionary) { Console.WriteLine($"{pair.Key}: {pair.Value}"); } // Using deconstruction with foreach (C# 7.0+) foreach (var (key, value) in dictionary) { Console.WriteLine($"{key}: {value}"); } // Breaking and continuing also work in foreach foreach (var item in collection) { if (ShouldSkip(item)) continue; if (ShouldStop(item)) break; Process(item); }
While loops check the condition before executing the loop body, while do-while loops execute the body at least once before checking the condition.
// While loop - may execute zero times while (condition) { // Loop body } // Example: reading until a condition is met while (!Console.KeyAvailable) { // Process until a key is pressed ProcessData(); } // Do-while loop - always executes at least once do { // Loop body } while (condition); // Example: menu system string choice; do { DisplayMenu(); choice = Console.ReadLine(); ProcessChoice(choice); } while (choice != "exit");
The lock statement prevents multiple threads from accessing a shared resource simultaneously, helping to avoid race conditions in multithreaded code.
// Define a lock object (private to avoid external locking) private readonly object _lockObject = new object(); // Using the lock statement public void AddItem(string item) { lock (_lockObject) { // This code can only be executed by one thread at a time _items.Add(item); _count++; } }
Best practices for locks:
- Use a private object for locking
- Keep the locked section as small as possible
- Avoid locking on 'this' or public objects
- Don't execute long-running or blocking operations inside a lock
- Consider using higher-level synchronization primitives for complex scenarios
The using
statement ensures that disposable resources are properly cleaned up, even if exceptions occur. It's an essential pattern for working with resources like files, network connections, and database connections that need to be explicitly released.
// Traditional using statement using (StreamReader reader = new StreamReader("file.txt")) { string content = reader.ReadToEnd(); // reader is automatically disposed here, even if an exception occurs } // Using declaration (C# 8.0+) using StreamWriter writer = new StreamWriter("output.txt"); writer.WriteLine("Hello, World!"); // writer is disposed at the end of the scope // Multiple resources in one using statement using (var connection = new SqlConnection(connectionString)) using (var command = new SqlCommand(queryString, connection)) { connection.Open(); using (var reader = command.ExecuteReader()) { // Process data } } // Using declaration with multiple resources (C# 8.0+) using var fileStream = new FileStream("data.bin", FileMode.Create); using var binaryWriter = new BinaryWriter(fileStream); binaryWriter.Write(42); // Both binaryWriter and fileStream are disposed at end of scope
The unsafe keyword allows you to write code that directly manipulates memory. This is primarily used for performance-critical operations or interop with native code.
// Must enable unsafe code in project settings or compiler options // <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in .csproj // Unsafe method public unsafe void ProcessBuffer(byte[] buffer) { fixed (byte* ptr = buffer) { // Direct memory manipulation for (int i = 0; i < buffer.Length; i++) { *(ptr + i) = (byte)(*(ptr + i) * 2); } } } // Unsafe context with pointer operations unsafe { int value = 10; int* pointer = &value; Console.WriteLine($"Value: {*pointer}"); // Increment the value through the pointer *pointer = 20; Console.WriteLine($"Updated value: {value}"); } // sizeof operator (only allowed in unsafe context) unsafe { Console.WriteLine($"Size of int: {sizeof(int)} bytes"); Console.WriteLine($"Size of double: {sizeof(double)} bytes"); }
The yield statement is used in iterator methods to provide values one at a time, enabling deferred execution and efficient handling of sequences.
// Simple iterator method public IEnumerable<int> GetNumbers(int count) { for (int i = 0; i < count; i++) { yield return i; } } // Iterator method with conditional logic public IEnumerable<int> GetEvenNumbers(int max) { for (int i = 0; i <= max; i++) { if (i % 2 == 0) { yield return i; } } } // Yield break to exit early public IEnumerable<int> GetNumbersUntil(int max, int stopAt) { for (int i = 0; i <= max; i++) { if (i == stopAt) { yield break; // Exit the iterator } yield return i; } } // Using yield to implement filtering public IEnumerable<T> Where<T>(IEnumerable<T> source, Func<T, bool> predicate) { foreach (var item in source) { if (predicate(item)) { yield return item; } } }
- Lazy evaluation: Results are computed only when needed
- Memory efficiency: No need to build the entire collection at once
- Composability: Iterator methods can be chained together
- Simplicity: Easier to write than manually implementing IEnumerator
Additional resources:
- C# Statements (Microsoft Docs)
- Selection statements - if, if-else, and switch (Microsoft Docs)
- Iteration statements (Microsoft Docs)
- Lock statement (Microsoft Docs)
- Using statement (Microsoft Docs)
- Unsafe keyword (Microsoft Docs)
- Yield (Microsoft Docs)
Methods are the fundamental building blocks of C# programs that encapsulate behavior and logic. They provide a way to organize code into reusable units, improving maintainability and readability.
C# offers various ways to define methods with different parameter types, return values, and syntax options to accommodate different programming styles and needs.
// Instance method public int Add(int a, int b) { return a + b; } // Static method public static double CalculateArea(double radius) { return Math.PI * radius * radius; } // Void method (no return value) public void PrintMessage(string message) { Console.WriteLine(message); }
Expression-bodied members provide a concise syntax for methods, properties, and other members that can be represented by a single expression.
// Expression-bodied method (one-line methods) public int Multiply(int a, int b) => a * b; // Expression-bodied property public string FullName => $"{FirstName} {LastName}";
C# provides flexible parameter passing options to handle different programming scenarios.
// Optional parameters public void Greet(string name, string greeting = "Hello") { Console.WriteLine($"{greeting}, {name}!"); } // Named arguments Greet(greeting: "Hi", name: "Alice"); // Ref parameters (pass by reference) public void Swap(ref int a, ref int b) { int temp = a; a = b; b = temp; } // Usage: Swap(ref x, ref y); // Out parameters (for returning multiple values) public bool TryParse(string input, out int result) { return int.TryParse(input, out result); } // Usage: bool success = TryParse("123", out int number); // In parameters (read-only reference - C# 7.2+) public double CalculateDistance(in Point p1, in Point p2) { // p1 and p2 cannot be modified return Math.Sqrt(Math.Pow(p2.X - p1.X, 2) + Math.Pow(p2.Y - p1.Y, 2)); } // Params array (variable number of arguments) public int Sum(params int[] numbers) { int total = 0; foreach (int number in numbers) { total += number; } return total; } // Usage: Sum(1, 2, 3, 4, 5);
Local functions allow you to define methods inside other methods, encapsulating helper logic that is only relevant to the containing method.
public int Factorial(int n) { // Local function defined inside another method int CalculateFactorial(int number) { if (number <= 1) return 1; return number * CalculateFactorial(number - 1); } return CalculateFactorial(n); }
Extension methods allow you to add methods to existing types without modifying the original type, making them particularly useful for extending types you don't control.
// Must be defined in a non-nested, non-generic static class public static class StringExtensions { // Extension method for string type public static bool IsNullOrEmpty(this string value) { return string.IsNullOrEmpty(value); } // Extension method with parameters public static string Truncate(this string value, int maxLength) { if (string.IsNullOrEmpty(value)) return value; return value.Length <= maxLength ? value : value.Substring(0, maxLength); } } // Usage string text = "Hello, World!"; bool isEmpty = text.IsNullOrEmpty(); // false string truncated = text.Truncate(5); // "Hello"
Lambda expressions provide a concise way to create anonymous functions, especially useful for LINQ queries, event handlers, and functional programming patterns.
// Func delegate (takes parameters, returns a value) Func<int, int, int> add = (a, b) => a + b; int sum = add(2, 3); // 5 // Action delegate (takes parameters, returns void) Action<string> print = message => Console.WriteLine(message); print("Hello!"); // Prints "Hello!" // Predicate delegate (takes parameters, returns bool) Predicate<int> isEven = number => number % 2 == 0; bool result = isEven(4); // true // Multi-line lambda Func<int, int> factorial = n => { int result = 1; for (int i = 1; i <= n; i++) { result *= i; } return result; };
Lambdas can now have default values for parameters:
Func<int, int, int> add = (x, y = 10) => x + y; int result = add(5); // 15
Method overloading allows multiple methods with the same name but different parameter lists, providing flexibility in how a method can be called.
// Method overloading (same name, different parameters) public void Display(int value) { Console.WriteLine($"Integer: {value}"); } public void Display(string value) { Console.WriteLine($"String: {value}"); } public void Display(int value, string format) { Console.WriteLine($"Formatted: {value.ToString(format)}"); }
Additional resources:
- Methods (Microsoft Docs)
- Expression-bodied members (Microsoft Docs)
- Method parameters (Microsoft Docs)
- Extension methods (Microsoft Docs)
- Lambda expressions (Microsoft Docs)
Delegates are type-safe pointers to methods, enabling flexible method invocation. They are often used for callbacks, event handling, and implementing the observer pattern. Delegates can point to static or instance methods and can be multicast (pointing to multiple methods).
public delegate int Operation(int x, int y); public int Add(int a, int b) => a + b; public int Multiply(int a, int b) => a * b; // Usage Operation op = Add; int result = op(3, 4); // 7 op += Multiply; // Multicast delegate
Events are built on delegates and provide a way for classes to notify subscribers when something happens. Events are typically used in GUI applications and other scenarios where you want to decouple the event source from the event handler.
public class Button
{
public event EventHandler Clicked;
protected virtual void OnClicked() =>
Clicked?.Invoke(this, EventArgs.Empty);
}
// Usage
var button = new Button();
button.Clicked += (sender, args) => Console.WriteLine("Clicked!");
Additional resources:
C# supports a variety of composite data types to organize and represent data.
Classes are reference types that encapsulate data and behavior.
// Basic class definition public class Person { // Fields private string name; private int age; // Properties public string Name { get { return name; } set { name = value; } } // Auto-implemented property public int Age { get; set; } // Read-only property public bool IsAdult => Age >= 18; // Constructors public Person() { // Default constructor } public Person(string name, int age) { Name = name; Age = age; } // Methods public void Introduce() { Console.WriteLine($"Hello, my name is {Name} and I'm {Age} years old."); } public string GetDescription() => $"{Name}, {Age} years old"; // Static members public static int MinimumAge { get; } = 0; public static bool IsValidAge(int age) { return age >= MinimumAge; } } // Usage Person person = new Person(); person.Name = "Alice"; person.Age = 30; person.Introduce(); Person bob = new Person("Bob", 25); string description = bob.GetDescription(); bool isAdult = bob.IsAdult; bool isValid = Person.IsValidAge(20);
Structs are value types and are suitable for small, immutable data structures.
Note that you cannot give initial values to a struct unless you make it static or const.
// Basic struct definition public struct Point { // Fields public double X { get; set; } public double Y { get; set; } // Constructor public Point(double x, double y) { X = x; Y = y; } // Methods public double DistanceFromOrigin() { return Math.Sqrt(X * X + Y * Y); } // Override ToString method public override string ToString() => $"({X}, {Y})"; } // Usage Point point = new Point(3, 4); double distance = point.DistanceFromOrigin(); // 5
Records are reference types designed for representing immutable data.
// Positional record (concise syntax) public record Person(string FirstName, string LastName, int Age); // Usage var person1 = new Person("John", "Doe", 30); var person2 = new Person("John", "Doe", 30); // Records have value-based equality bool areEqual = person1 == person2; // true // Non-destructive mutation with 'with' expression var person3 = person1 with { Age = 31 }; // Records can also be defined with standard syntax for more flexibility public record Employee { public string FirstName { get; init; } public string LastName { get; init; } public int Id { get; init; } // Additional members can be defined public string FullName => $"{FirstName} {LastName}"; }
Record structs combine the value semantics of structs with the special features of records.
// Record struct public record struct Point(double X, double Y); // Mutable record struct public record struct MutablePoint { public double X { get; set; } public double Y { get; set; } public double Distance => Math.Sqrt(X * X + Y * Y); }
Interfaces define a contract that classes can implement.
// Interface definition public interface IShape { double Area { get; } double Perimeter { get; } void Draw(); string GetDescription() => $"Shape with area {Area} and perimeter {Perimeter}"; // Default implementation (C# 8.0+) } // Implementing an interface public class Circle : IShape { public double Radius { get; } public Circle(double radius) { Radius = radius; } public double Area => Math.PI * Radius * Radius; public double Perimeter => 2 * Math.PI * Radius; public void Draw() { Console.WriteLine("Drawing a circle"); } // Override default implementation public string GetDescription() => $"Circle with radius {Radius}"; }
From C# 8.0, interfaces can have default implementations for methods and properties, allowing you to provide a base implementation that can be overridden by implementing classes.
public interface ILogger { void Log(string message); // Default implementation void LogError(string message) => Log($"ERROR: {message}"); void LogWarning(string message) => Log($"WARNING: {message}"); // Static method in interface static string FormatMessage(string level, string message) => $"[{level}] {message}"; } // Implement only required methods public class MinimalLogger : ILogger { public void Log(string message) { Console.WriteLine(message); } // Other methods use default implementations }
Enums define a set of named constants.
// Basic enum public enum DayOfWeek { Sunday, // 0 Monday, // 1 Tuesday, // 2 Wednesday, // 3 Thursday, // 4 Friday, // 5 Saturday // 6 } // Enum with explicit values public enum HttpStatusCode { OK = 200, Created = 201, BadRequest = 400, Unauthorized = 401, NotFound = 404, InternalServerError = 500 } // Enum with flags attribute (for bitwise operations) [Flags] public enum Permissions { None = 0, Read = 1, Write = 2, Execute = 4, All = Read | Write | Execute } // Usage DayOfWeek today = DayOfWeek.Monday; HttpStatusCode status = HttpStatusCode.OK; // Convert between enum and integer int dayValue = (int)today; DayOfWeek convertedDay = (DayOfWeek)3; // Wednesday // Parsing from string DayOfWeek parsed = Enum.Parse<DayOfWeek>("Friday"); bool success = Enum.TryParse("Sunday", out DayOfWeek result); // Using flags Permissions userPermissions = Permissions.Read | Permissions.Write; bool canRead = userPermissions.HasFlag(Permissions.Read); // true bool canExecute = userPermissions.HasFlag(Permissions.Execute); // false
Tuples group multiple values without defining a specific type.
// Creating tuples (C# 7.0+) (int, string) pair = (1, "one"); var person = (Name: "Alice", Age: 30); // Accessing tuple elements int id = pair.Item1; string value = pair.Item2; string name = person.Name; int age = person.Age; // Tuple deconstruction var (newId, newValue) = pair; var (newName, newAge) = person; // Using tuples as method return values (int Min, int Max) FindMinMax(int[] numbers) { return (numbers.Min(), numbers.Max()); } var result = FindMinMax(new[] { 1, 2, 3, 4, 5 }); Console.WriteLine($"Min: {result.Min}, Max: {result.Max}"); // Tuple as method parameter void ProcessData((string Name, int Age) person) { Console.WriteLine($"Processing data for {person.Name}, {person.Age}"); }
Nullable types represent values that can be null.
// Nullable value types int? nullableInt = null; double? nullableDouble = 3.14; // Checking for null if (nullableInt.HasValue) { int value = nullableInt.Value; } // Null-coalescing operator int result = nullableInt ?? 0; // If nullableInt is null, use 0 // Nullable reference types (C# 8.0+) // Enable with: #nullable enable string? nullableString = null; string nonNullableString = "Hello"; // Cannot be null // Null-conditional operator int? length = nullableString?.Length; // null if nullableString is null // Null-coalescing assignment (C# 8.0+) nullableString ??= "Default";
Generics let you define type-safe, reusable classes, methods, and interfaces. They improve code reuse, type safety, and performance by avoiding boxing/unboxing overhead.
Generic class:
public class Repository<T> { private readonly List<T> _items = new(); public void Add(T item) => _items.Add(item); public T Get(int index) => _items[index]; } // Usage var intRepo = new Repository<int>(); intRepo.Add(42); int number = intRepo.Get(0);
Generic methods:
public T Echo<T>(T input) => input; // Usage string message = Echo("Hello"); int number = Echo(123);
Constraints (limit generic types):
public class EmployeeRepository<T> where T : Employee, new() { public T Create() => new T(); }
Additional resources:
Classes are the foundation of object-oriented programming in C#, serving as blueprints for creating objects that encapsulate data and behavior.
Inheritance is a powerful mechanism that enables code reuse and polymorphism by allowing classes to inherit attributes and methods from parent classes.
Proper use of these features helps create maintainable, extensible code with clear hierarchies.
Constructors are special methods that initialize objects. They set initial state, enforce invariants, and can chain to other constructors to share initialization logic. Understanding constructor patterns is essential for creating maintainable class hierarchies.
public class Person { public string Name { get; set; } public int Age { get; set; } // Default constructor public Person() { Name = "Unknown"; Age = 0; } // Parameterized constructor public Person(string name, int age) { Name = name; Age = age; } // Static constructor (called once before type is used) static Person() { Console.WriteLine("Person type initialized"); } } // Constructor chaining public class Employee : Person { public string Department { get; set; } // Call the base class constructor public Employee(string name, int age, string department) : base(name, age) { Department = department; } // Chain to another constructor in the same class public Employee(string name, int age) : this(name, age, "General") { } }
Primary constructors are a new feature in C# 12 that simplify class initialization by allowing constructor parameters to be defined directly in the class declaration. This reduces boilerplate code and makes the relationship between constructor parameters and class members more explicit.
// Class with primary constructor public class Person(string name, int age) { // Properties initialized by primary constructor parameters public string Name { get; } = name; public int Age { get; } = age; // Can use constructor parameters directly in methods public string Introduce() => $"My name is {name} and I'm {age} years old"; // Can still have additional constructors public Person(string name) : this(name, 0) { Console.WriteLine("Created a person with default age"); } } // Inheritance with primary constructors public class Employee(string name, int age, string department) : Person(name, age) { public string Department { get; } = department; // Using base constructor parameters public override string Introduce() => $"{base.Introduce()} working in {department}"; } // Usage var alice = new Person("Alice", 30); var bob = new Employee("Bob", 25, "Engineering");
Inheritance allows a class (derived class) to inherit properties, methods, and events from another class (base class). This promotes code reuse and establishes an "is-a" relationship between classes. In C#, a class can inherit from only one base class but can implement multiple interfaces.
// Base class public class Animal { public string Name { get; set; } public Animal(string name) { Name = name; } public virtual void MakeSound() { Console.WriteLine("Some generic animal sound"); } // Non-overridable method public void Sleep() { Console.WriteLine($"{Name} is sleeping"); } } // Derived class public class Dog : Animal { public string Breed { get; set; } public Dog(string name, string breed) : base(name) { Breed = breed; } // Override base class method public override void MakeSound() { Console.WriteLine("Woof!"); } // New method public void Fetch() { Console.WriteLine($"{Name} is fetching the ball"); } }
Abstract classes serve as incomplete templates that cannot be instantiated directly but must be inherited by concrete classes. They're useful when you want to define common functionality while forcing derived classes to implement specific methods. An abstract class can have both abstract members (without implementation) and concrete members (with implementation).
// Abstract class public abstract class Shape { // Abstract property (must be implemented) public abstract double Area { get; } // Abstract method (must be implemented) public abstract double Perimeter(); // Concrete method public void PrintInfo() { Console.WriteLine($"Area: {Area}, Perimeter: {Perimeter()}"); } } // Concrete implementation public class Rectangle : Shape { public double Width { get; set; } public double Height { get; set; } public Rectangle(double width, double height) { Width = width; Height = height; } public override double Area => Width * Height; public override double Perimeter() => 2 * (Width + Height); }
Sealed classes prevent inheritance, making them useful for security-sensitive classes or when inheritance would break functionality. Sealed methods prevent further overriding in derived classes, ensuring that specific implementations remain unchanged throughout the inheritance chain.
// Sealed class (cannot be inherited) public sealed class Utility { public static void DoSomething() { } } public class Base { // Virtual method that can be overridden public virtual void Method1() { } // Virtual method that can be overridden public virtual void Method2() { } } public class Derived : Base { public override void Method1() { } // Sealed method (can't be overridden further in inheritance chain) public sealed override void Method2() { } } public class Further : Derived { public override void Method1() { } // OK // public override void Method2() { } // Error: cannot override sealed method }
Polymorphism allows objects to be treated as instances of their parent class rather than their actual type. This enables more flexible and extensible code by letting you write methods that operate on base classes but respond differently based on the actual object type at runtime.
// Base class reference can refer to derived class objects Animal myPet = new Dog("Fido", "Beagle"); myPet.MakeSound(); // Outputs "Woof!" // Array of base class can hold derived objects Animal[] animals = new Animal[] { new Dog("Fido", "Beagle"), new Cat("Whiskers"), new Rabbit("Hopper") }; // Polymorphic behavior foreach (Animal animal in animals) { Console.WriteLine($"{animal.Name} says:"); animal.MakeSound(); // Each animal makes its own sound } // Type checking and casting if (animals[0] is Dog dog) { dog.Fetch(); // Access Dog-specific method } // Explicit casting Dog anotherDog = (Dog)animals[0];
When designing class hierarchies, consider these guidelines:
- Use inheritance when there is a true "is-a" relationship between classes
- Prefer composition over inheritance for "has-a" relationships
- Use abstract classes when you want to provide common behavior with forced specialization
- Use sealed classes for security-sensitive code or to prevent unintended inheritance
- Implement interfaces for defining capabilities that can be shared across unrelated classes
Additional resources:
- Classes and objects (Microsoft Docs)
- Inheritance (Microsoft Docs)
- Abstract classes and methods (Microsoft Docs)
- Primary constructors (Microsoft Docs)
- C# object-oriented programming best practices (Microsoft Learn)
Collections in C# provide powerful ways to store, manage, and manipulate groups of related objects. The .NET framework offers various collection types optimized for different scenarios, from simple arrays to complex specialized collections. Choosing the right collection type is essential for writing efficient and maintainable code.
Collection expressions are a concise way to initialize collections, introduced in C# 12. They provide a unified syntax for creating and initializing different collection types.
// Creating collections with the new collection expressions syntax int[] numbers = [1, 2, 3, 4, 5]; // Array List<string> names = ["Alice", "Bob", "Charlie"]; // List HashSet<char> letters = ['a', 'b', 'c']; // HashSet Dictionary<string, int> ages = [ // Dictionary "Alice" => 30, "Bob" => 25, "Charlie" => 35 ]; // Spread operator - combining collections int[] moreNumbers = [0, .. numbers, 6]; // [0, 1, 2, 3, 4, 5, 6] string[] firstThree = [.. names[0..3]]; // ["Alice", "Bob", "Charlie"] // Pattern matching with collection expressions bool IsValidPoint(int[] point) => point is [var x, var y] && x >= 0 && y >= 0;
Arrays are fixed-size collections of elements of the same type. They provide efficient random access but have a predetermined size that cannot change after creation.
// Declaration and initialization int[] numbers = new int[5]; // Array of 5 integers with default values (0) int[] initialized = new int[] { 1, 2, 3, 4, 5 }; // Initialized array int[] shorthand = { 1, 2, 3, 4, 5 }; // Shorthand initialization string[] names = { "Alice", "Bob", "Charlie" }; // Accessing elements int firstNumber = numbers[0]; // First element (zero-based indexing) numbers[0] = 10; // Assign value to first element // Multi-dimensional arrays int[,] matrix = new int[3, 3]; // 3x3 2D array matrix[0, 0] = 1; // Assign to specific position int[,] initialized2D = { // Initialize 2D array { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } }; // Jagged arrays (arrays of arrays) int[][] jagged = new int[3][]; jagged[0] = new int[] { 1, 2, 3 }; jagged[1] = new int[] { 4, 5 }; jagged[2] = new int[] { 6, 7, 8, 9 }; // Array properties and methods int length = numbers.Length; // Number of elements Array.Sort(numbers); // Sort array in-place Array.Reverse(numbers); // Reverse array in-place int index = Array.IndexOf(names, "Bob"); // Find index of element bool exists = Array.Exists(numbers, n => n > 10); // Check if condition exists
Lists are dynamic arrays that can grow or shrink in size. They provide flexibility and are generally the go-to collection type for most scenarios when you need a sequence of elements.
using System.Collections.Generic; // Create a list List<string> names = new List<string>(); // Empty list List<int> numbers = new List<int> { 1, 2, 3 }; // Initialized list // Add elements names.Add("Alice"); // Add single element names.AddRange(new[] { "Bob", "Charlie" }); // Add multiple elements // Access elements string first = names[0]; // Access by index names[0] = "Alicia"; // Modify by index // Remove elements names.Remove("Bob"); // Remove specific element names.RemoveAt(0); // Remove element at index names.RemoveAll(x => x.StartsWith("C")); // Remove all that match condition names.Clear(); // Remove all elements // Search and query bool contains = numbers.Contains(2); // Check if contains value int index = numbers.IndexOf(3); // Find index of element List<int> filtered = numbers.FindAll(n => n > 1); // Find all matching elements int found = numbers.Find(n => n > 2); // Find first matching element // Other operations int count = numbers.Count; // Number of elements numbers.Sort(); // Sort list in-place numbers.Reverse(); // Reverse list in-place numbers.ForEach(n => Console.WriteLine(n)); // Perform action on each element
Dictionaries store key-value pairs for fast lookups by key. They are essential when you need to quickly access values based on unique identifiers.
using System.Collections.Generic; // Create a dictionary Dictionary<string, int> ages = new Dictionary<string, int>(); Dictionary<string, string> capitals = new Dictionary<string, string> { { "USA", "Washington D.C." }, { "UK", "London" }, ["France"] = "Paris" // Alternative initialization syntax }; // Add entries ages.Add("Alice", 30); ages["Bob"] = 25; // Add or update using indexer // Access values int aliceAge = ages["Alice"]; // Access by key (throws if not found) bool success = ages.TryGetValue("Charlie", out int charlieAge); // Safe access // Check existence bool containsKey = ages.ContainsKey("Alice"); bool containsValue = ages.ContainsValue(25); // Remove entries bool removed = ages.Remove("Bob"); // Iterate through dictionary foreach (KeyValuePair<string, int> pair in ages) { Console.WriteLine($"{pair.Key}: {pair.Value}"); } // Or using deconstruction (C# 7.0+) foreach (var (name, age) in ages) { Console.WriteLine($"{name}: {age}"); }
HashSets store unique elements with fast lookup, insertion, and deletion. They're ideal for maintaining collections of unique items or performing set operations.
using System.Collections.Generic; // Create a HashSet HashSet<int> uniqueNumbers = new HashSet<int>(); HashSet<string> fruits = new HashSet<string> { "Apple", "Banana", "Orange" }; // Add elements uniqueNumbers.Add(1); // Returns true if added uniqueNumbers.Add(1); // Returns false (already exists) uniqueNumbers.UnionWith(new[] { 2, 3, 4 }); // Add multiple elements // Check membership bool contains = fruits.Contains("Apple"); // Fast lookup // Remove elements bool removed = fruits.Remove("Banana"); // Set operations HashSet<int> setA = new HashSet<int> { 1, 2, 3 }; HashSet<int> setB = new HashSet<int> { 3, 4, 5 }; setA.UnionWith(setB); // Union: { 1, 2, 3, 4, 5 } setA.IntersectWith(setB); // Intersection: { 3 } setA.ExceptWith(setB); // Difference: { 1, 2 } setA.SymmetricExceptWith(setB); // Symmetric difference: { 1, 2, 4, 5 } bool isSubset = setA.IsSubsetOf(setB); bool isSuperset = setA.IsSupersetOf(setB);
Queues (FIFO - first in, first out) and Stacks (LIFO - last in, first out) are specialized collections that support specific access patterns common in many algorithms and data processing scenarios.
using System.Collections.Generic; // Queue (First In, First Out) Queue<string> queue = new Queue<string>(); queue.Enqueue("First"); // Add to end queue.Enqueue("Second"); queue.Enqueue("Third"); string next = queue.Peek(); // View next item without removing string dequeued = queue.Dequeue(); // Remove and return next item int count = queue.Count; // Number of items bool contains = queue.Contains("Second"); // Stack (Last In, First Out) Stack<int> stack = new Stack<int>(); stack.Push(1); // Add to top stack.Push(2); stack.Push(3); int top = stack.Peek(); // View top item without removing int popped = stack.Pop(); // Remove and return top item int stackCount = stack.Count; // Number of items bool stackContains = stack.Contains(2);
LINQ provides powerful query capabilities for collections, making it easier to filter, transform, and aggregate data. It brings database-like query operations to in-memory collections.
using System.Linq; List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; // Filtering var evens = numbers.Where(n => n % 2 == 0); // [2, 4, 6, 8, 10] var greaterThanFive = numbers.Where(n => n > 5); // [6, 7, 8, 9, 10] // Transformation var doubled = numbers.Select(n => n * 2); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20] var numberObjects = numbers.Select(n => new { Value = n, IsEven = n % 2 == 0 }); // Ordering var ascending = numbers.OrderBy(n => n); // [1, 2, 3, ...] var descending = numbers.OrderByDescending(n => n); // [10, 9, 8, ...] var complex = numbers.OrderBy(n => n % 3).ThenByDescending(n => n); // Multiple criteria // Aggregation int sum = numbers.Sum(); // 55 int min = numbers.Min(); // 1 int max = numbers.Max(); // 10 double average = numbers.Average(); // 5.5 int product = numbers.Aggregate((a, b) => a * b); // 3628800 (factorial of 10) // Quantifiers bool allEven = numbers.All(n => n % 2 == 0); // false bool anyEven = numbers.Any(n => n % 2 == 0); // true bool containsSeven = numbers.Contains(7); // true // Partitioning var firstThree = numbers.Take(3); // [1, 2, 3] var skipFirstThree = numbers.Skip(3); // [4, 5, 6, 7, 8, 9, 10] var takeLast = numbers.TakeLast(2); // [9, 10] var skipLast = numbers.SkipLast(2); // [1, 2, 3, 4, 5, 6, 7, 8] // Element operations int first = numbers.First(); // 1 int firstEven = numbers.First(n => n % 2 == 0); // 2 int lastOdd = numbers.Last(n => n % 2 != 0); // 9 int single = numbers.Where(n => n == 5).Single(); // 5 // Grouping var groups = numbers.GroupBy(n => n % 3); // Groups by remainder when divided by 3 foreach (var group in groups) { Console.WriteLine($"Remainder {group.Key}: {string.Join(", ", group)}"); } // Query syntax (alternative to method syntax) var queryResult = from n in numbers where n > 5 orderby n descending select n * 2;
Additional resources:
- Collections overview (Microsoft Docs)
- Collection expressions (Microsoft Docs)
- LINQ (Microsoft Docs)
- IEnumerable and IQueryable
- Choosing a collection type (Microsoft Docs)
- System.Collections.Generic Namespace (Microsoft Docs)
C# supports various pattern matching techniques for more expressive conditional logic.
Type patterns allow you to check the type of an object and cast it in a single operation. This is particularly useful in is
expressions and switch statements.
// Type pattern - check if object is of a specific type object value = "Hello"; if (value is string text) { // 'text' is the value cast to string, available in this scope Console.WriteLine(text.ToUpper()); } // Switch expression with type patterns (C# 8.0+) string GetDisplayName(object item) => item switch { Person p => $"Person: {p.Name}", DateTime d => $"Date: {d.ToShortDateString()}", int i => $"Number: {i}", string s => $"Text: {s}", null => "Null value", _ => "Unknown type" // Default case };
Property patterns allow you to match properties of an object directly in a pattern. This is useful for filtering or extracting data from complex objects.
// Property pattern to match object properties if (person is { Name: "Alice", Age: >= 30 }) { Console.WriteLine("Found Alice who is 30 or older"); } // Switch expression with property patterns string GetAgeCategory(Person person) => person switch { { Age: < 13 } => "Child", { Age: < 20 } => "Teenager", { Age: < 65 } => "Adult", _ => "Senior" }; // Nested property patterns if (order is { Customer: { Name: "Alice" } }) { Console.WriteLine("This is Alice's order"); }
Tuple patterns allow you to match multiple values at once, making it easy to work with data structures that contain multiple related values.
// Tuple pattern (int x, int y) = (5, 10); string GetQuadrant(int x, int y) => (x, y) switch { (> 0, > 0) => "First quadrant", (< 0, > 0) => "Second quadrant", (< 0, < 0) => "Third quadrant", (> 0, < 0) => "Fourth quadrant", (0, 0) => "Origin", (_, 0) => "X-axis", (0, _) => "Y-axis" };
Logical patterns allow you to combine multiple conditions using logical operators like and
, or
, and not
. This is useful for creating complex matching conditions.
// 'and', 'or', and 'not' patterns (C# 9.0+) if (person is { Age: > 20 and < 30, Name: "Alice" or "Bob" }) { Console.WriteLine("Person is between 20-30 and named Alice or Bob"); } // In switch expression string CheckValue(int value) => value switch { > 0 and < 10 => "Single digit positive", >= 10 and < 100 => "Double digit positive", < 0 and not -1 => "Negative, but not -1", 0 or -1 => "Zero or negative one", _ => "Other" };
List patterns allow you to match against the structure of collections, making it easier to work with arrays and lists.
// List pattern in C# 11 var numbers = new[] { 1, 2, 3, 4 }; bool IsFirstTwoPositive(int[] numbers) => numbers is [> 0, > 0, ..]; string DescribeArray(int[] arr) => arr switch { [] => "Empty array", [var single] => $"Single item: {single}", [var first, var second] => $"Two items: {first}, {second}", [var first, .. var middle, var last] => $"Multiple items, starts with {first}, ends with {last}", _ => "Unknown pattern" };
Discard patterns (underscore _
) allow you to ignore specific values in a pattern match. This is useful when you only care about certain values and want to ignore the rest.
// Discard pattern (underscore) to ignore values string GetSign(int number) => number switch { < 0 => "Negative", > 0 => "Positive", _ => "Zero" }; // Multiple discards (string, int) person = ("Alice", 30); var (name, _) = person; // Discard the age
Exception handling is a critical aspect of robust C# applications, which allows you to manage errors and unexpected conditions. Well-designed exception handling balances providing useful feedback to users, maintaining application stability, and preserving valuable diagnostic information for developers.
The try-catch-finally pattern forms the backbone of exception handling in C#. Code that might throw exceptions is placed in the try
block, specific exception types are caught and handled in catch
blocks, and cleanup code that should always execute (regardless of exceptions) goes in the finally
block.
try { // Code that might throw an exception int result = 10 / 0; // Will throw DivideByZeroException File.ReadAllText("nonexistent.txt"); // Will throw FileNotFoundException } catch (DivideByZeroException ex) { // Handle specific exception Console.WriteLine($"Math error: {ex.Message}"); // Log the exception details for developers Logger.LogError(ex, "Division by zero occurred"); // Provide user-friendly message ShowUserErrorMessage("A calculation error occurred. Please try different input values."); } catch (FileNotFoundException ex) when (ex.FileName.Contains("nonexistent")) { // Exception filter (C# 6.0+) allows conditionals to catch blocks Console.WriteLine($"File not found: {ex.FileName}"); } catch (IOException ex) { // Handle another specific exception Console.WriteLine($"IO error: {ex.Message}"); } catch (Exception ex) { // Catch all other exceptions Console.WriteLine($"Unexpected error: {ex.Message}"); throw; // Re-throw the exception to preserve stack trace } finally { // Code that always executes, whether an exception occurred or not Console.WriteLine("This always runs"); // Common cleanup operations: // - Close file handles // - Release database connections // - Dispose of unmanaged resources // - Return pooled objects }
-
Specific vs. general Exception catching:
- Catch specific exceptions when you can handle them in a meaningful way
- Only catch
Exception
as a last resort to log unexpected errors or provide generic fallbacks - Avoid empty catch blocks that swallow exceptions without handling them
-
Exception filters:
- Use when you only want to catch exceptions that meet certain criteria
- Helps avoid unnecessary exception handling and maintain more precise control flow
-
Re-throwing Exceptions:
- Use
throw;
(without specified exception) to preserve the original stack trace - Only use
throw ex;
when you want to deliberately reset the stack trace (rarely needed)
- Use
-
Exception prevention:
- Use
TryParse
patterns and null checking to prevent exceptions when possible - Reserve exception handling for truly exceptional conditions, not for normal control flow
- Consider validation before operations that might throw exceptions
- Use
Throwing exceptions should be done deliberately and with consideration for the calling code. This includes choosing appropriate exception types, providing meaningful messages, and including relevant context.
// Throw exceptions void ProcessData(string data) { if (data == null) { throw new ArgumentNullException(nameof(data), "Data cannot be null."); } if (data.Length == 0) { throw new ArgumentException("Data cannot be empty.", nameof(data)); } if (!IsValidFormat(data)) { throw new FormatException($"Data '{data}' is not in the required format."); } // Process valid data... } // Rethrowing exceptions try { ProcessData(null); } catch (Exception ex) { // Log the exception Console.WriteLine($"Error: {ex.Message}"); // Preserve stack trace when rethrowing throw; // This would reset the stack trace (usually undesirable): // throw ex; }
- System.ArgumentException: Use when a method argument is invalid
- System.ArgumentNullException: Use when an argument is unexpectedly null
- System.InvalidOperationException: Use when the object state doesn't allow the operation
- System.NotImplementedException: Use for methods that aren't implemented yet
- System.NotSupportedException: Use for operations that won't be implemented
- System.IO.IOException: Use for file system and I/O errors
- System.FormatException: Use when string format is incorrect for the expected type
- Custom exceptions: Create when built-in exceptions don't appropriately describe your error
Custom exceptions allow you to create domain-specific error types that provide more meaningful context about what went wrong in your application. They should be created when built-in exception types don't adequately capture the specific error condition.
// Define custom exception public class CustomerNotFoundException : Exception { public int CustomerId { get; } public CustomerNotFoundException(int customerId) : base($"Customer with ID {customerId} was not found") { CustomerId = customerId; } public CustomerNotFoundException(int customerId, Exception innerException) : base($"Customer with ID {customerId} was not found", innerException) { CustomerId = customerId; } // For serialization support (important for distributed applications) protected CustomerNotFoundException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { CustomerId = info.GetInt32(nameof(CustomerId)); } // Override GetObjectData for proper serialization public override void GetObjectData(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) { base.GetObjectData(info, context); info.AddValue(nameof(CustomerId), CustomerId); } } // Usage void ProcessCustomer(int customerId) { if (!customerDatabase.Exists(customerId)) { throw new CustomerNotFoundException(customerId); } // Process customer... }
Exception handling has performance implications that should be considered in your design:
- The
try
block itself has minimal overhead when no exceptions occur - Throwing and catching exceptions is relatively expensive and should not be used for normal control flow
- Use patterns like
TryParse
and null checking to avoid throwing exceptions in expected scenarios - Reserve exceptions for truly exceptional conditions that shouldn't happen in normal operation
- Consider using status return codes or
Result<T>
pattern for expected error conditions in performance-critical code
Additional resources:
- Exception handling (Microsoft Docs)
- Best practices for Exceptions (Microsoft Docs)
- Creating and throwing Exceptions (Microsoft Docs)
- IDisposable pattern (Microsoft Docs)
- Exception handling in async code (Microsoft Docs)
Asynchronous programming in C# allows you to write non-blocking code that can improve responsiveness and throughput, particularly in I/O-bound and network operations. Modern C# provides elegant syntax with async/await that makes asynchronous code almost as straightforward to write as synchronous code, while maintaining the performance benefits.
Understanding when to use async code is crucial. Use async code for I/O-bound operations:
- Network requests
- Database operations
- File system operations
- Web API calls
- User input
Don't use it for CPU-bound operations. For computationally intensive work, use:
Task.Run
to offload work to a background thread- Parallel processing APIs for data parallelism
// Async method declaration public async Task<string> DownloadDataAsync(string url) { // Create HTTP client using HttpClient client = new HttpClient(); // Asynchronously wait for the HTTP request string result = await client.GetStringAsync(url); return result; } // Calling async methods public async Task ProcessDataAsync() { Console.WriteLine("Starting data download..."); // Await the asynchronous operation string data = await DownloadDataAsync("https://example.com/api/data"); Console.WriteLine($"Downloaded {data.Length} bytes"); } // Void async methods (event handlers) public async void Button_Click(object sender, EventArgs e) { try { await ProcessDataAsync(); MessageBox.Show("Download complete!"); } catch (Exception ex) { MessageBox.Show($"Error: {ex.Message}"); } }
- Naming convention: Append "Async" to method names that return Task or Task
- Return types:
- Use Task for methods that return a value
- Use Task for methods that don't return a value
- Avoid async void except for event handlers
- Async all the way: Convert entire call chains to async to avoid blocking
- ConfigureAwait: Use ConfigureAwait(false) in library code to avoid forcing context
The Task-based asynchronous pattern (TAP) is the recommended approach for asynchronous programming in C#. It uses Task
and Task<T>
to represent ongoing work and provides rich composition capabilities.
// Create and return a Task public Task<int> CalculateAsync(int a, int b) { return Task.Run(() => { // Simulate CPU-bound work Thread.Sleep(1000); return a + b; }); } // Task.WhenAll - run multiple tasks in parallel public async Task ProcessMultipleAsync() { Task<string> task1 = DownloadDataAsync("https://example.com/api/1"); Task<string> task2 = DownloadDataAsync("https://example.com/api/2"); Task<string> task3 = DownloadDataAsync("https://example.com/api/3"); // Wait for all tasks to complete string[] results = await Task.WhenAll(task1, task2, task3); // Process results foreach (string result in results) { Console.WriteLine($"Result length: {result.Length}"); } } // Task.WhenAny - wait for the first task to complete public async Task<string> GetFastestResponseAsync() { Task<string> task1 = DownloadDataAsync("https://example.com/api/1"); Task<string> task2 = DownloadDataAsync("https://example.com/api/2"); // Wait for the first task to complete Task<string> completedTask = await Task.WhenAny(task1, task2); // Get the result from the completed task return await completedTask; }
When to use different task composition methods:
- Task.WhenAll: Use when you need the results of all operations and they can run concurrently
- Task.WhenAny: Use for implementing timeouts, racing operations, or taking the first available result
- Task.Run: Use for CPU-bound work that needs to be offloaded from the current thread
- Task.Delay: Use for implementing timeouts or periodic operations in async methods
public async Task ExceptionHandlingExampleAsync() { try { // Multiple awaits in one try block string data = await DownloadDataAsync("https://example.com/api/data"); int result = await ProcessDataAsync(data); await SaveResultAsync(result); } catch (HttpRequestException ex) { // Handle network-related exceptions Console.WriteLine($"Network error: {ex.Message}"); } catch (JsonException ex) { // Handle JSON parsing exceptions Console.WriteLine($"Invalid data format: {ex.Message}"); } catch (Exception ex) { // Handle all other exceptions Console.WriteLine($"Unexpected error: {ex.Message}"); } } // Aggregate exceptions with Task.WhenAll public async Task HandleMultipleExceptionsAsync() { var tasks = new List<Task>(); for (int i = 0; i < 5; i++) { int taskNumber = i; tasks.Add(Task.Run(async () => { if (taskNumber % 2 == 0) { await Task.Delay(100); throw new Exception($"Task {taskNumber} failed"); } })); } try { await Task.WhenAll(tasks); } catch (Exception) { // Check for all exceptions foreach (var task in tasks) { if (task.Exception != null) { Console.WriteLine(task.Exception.InnerException.Message); } } } }
- Always handle exceptions in async methods, especially in async void methods
- Be aware that
Task.WhenAll
throws only the first exception; check all tasks for exceptions - Use
AggregateException.Flatten()
to simplify handling multiple exceptions - Consider using a global exception handler for unhandled exceptions in async code
- Remember that exceptions in async methods are captured and placed on the returned Task
Cancellation allows long-running operations to be stopped gracefully. The CancellationToken
mechanism provides a standardized way to implement cancellation in async methods.
public async Task DemonstrateCancellationAsync() { // Create cancellation token source using CancellationTokenSource cts = new CancellationTokenSource(); // Set timeout after 5 seconds cts.CancelAfter(TimeSpan.FromSeconds(5)); try { await LongRunningOperationAsync(cts.Token); } catch (OperationCanceledException) { Console.WriteLine("Operation was canceled"); } } public async Task LongRunningOperationAsync(CancellationToken cancellationToken) { for (int i = 0; i < 100; i++) { // Check cancellation before doing work cancellationToken.ThrowIfCancellationRequested(); // Perform some work Console.WriteLine($"Working on step {i}"); // Wait with cancellation support await Task.Delay(100, cancellationToken); } } // Example of cancelling a web request public async Task<string> DownloadWithTimeoutAsync(string url, TimeSpan timeout) { using CancellationTokenSource cts = new CancellationTokenSource(timeout); using HttpClient client = new HttpClient(); try { return await client.GetStringAsync(url, cts.Token); } catch (OperationCanceledException) { throw new TimeoutException($"The request to {url} timed out after {timeout.TotalSeconds} seconds"); } }
ValueTask
and async streams are newer features that enhance async programming in specific scenarios by improving performance and extending the asynchronous model to sequences.
// ValueTask for potentially synchronous, high-performance scenarios public ValueTask<int> GetValueAsync(bool alreadyCached, int cachedValue) { if (alreadyCached) { // Return immediately without allocating a Task return new ValueTask<int>(cachedValue); } // Fall back to async path return new ValueTask<int>(GetValueSlowlyAsync()); } private async Task<int> GetValueSlowlyAsync() { await Task.Delay(100); return 42; } // Async streams with IAsyncEnumerable public async IAsyncEnumerable<string> GetDataStreamAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { for (int i = 0; i < 10; i++) { // Check cancellation cancellationToken.ThrowIfCancellationRequested(); // Simulate work await Task.Delay(100, cancellationToken); // Yield a result yield return $"Item {i}"; } } // Consuming async streams public async Task ConsumeAsyncStreamAsync() { await foreach (string item in GetDataStreamAsync()) { Console.WriteLine(item); } // With cancellation using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); try { await foreach (string item in GetDataStreamAsync().WithCancellation(cts.Token)) { Console.WriteLine(item); } } catch (OperationCanceledException) { Console.WriteLine("Stream processing was canceled"); } }
How you organize your C# code significantly impacts its readability, maintainability, and extensibility. Well-organized code follows consistent patterns, respects separation of concerns, and leverages language features to create clear boundaries between components.
Modern C# includes numerous features that help enforce good code organization principles.
Namespaces in C# provide a way to organize code into logical groups and prevent naming conflicts. They create a hierarchical structure for your types (yes, they can be nested), making large codebases more manageable and allowing for intuitive navigation.
// Namespace declaration namespace MyApplication.DataAccess { public class Database { // Class implementation } } // File-scoped namespaces (C# 10.0+) namespace MyApplication.Business; public class Customer { // Class implementation }
- Structure namespaces to reflect logical organization, not folder structure
- Consider using a company or project name as the top-level namespace
- Group related functionality within namespace hierarchies
- Avoid deeply nested namespaces (more than 3-4 levels)
- Don't put different functionality in the same namespace just because they're in the same assembly
Using directives specify which namespaces are referenced in your code, allowing you to use types from those namespaces without fully qualifying them. They improve code readability by reducing repetition.
// Import namespace using System; using System.Collections.Generic; using System.Linq; // Alias namespace using IO = System.IO; // Static imports (C# 6.0+) using static System.Math; using static System.Console; // Combined with global using (C# 10.0+) global using System; global using System.Collections.Generic; // Using aliases for types (C# 12+) using Point = (int X, int Y); using CustomerName = string; using RGB = (byte Red, byte Green, byte Blue); // Multiple global using directives in a single file (GlobalUsings.cs) global using System.Collections.Generic; global using System.Linq; global using System.Threading; global using System.Threading.Tasks;
- Place
using
directives at the top of the file, outside of namespace declarations - Order
using
directives alphabetically, with System namespaces first - Use
global using
for commonly used namespaces across many files - Use static imports sparingly and only for frequently used static members
- Consider using aliases to improve readability or avoid ambiguity
File-scoped types are accessible only within the file where they're defined, allowing you to create helper classes, interfaces, or enums that are truly private to their implementation file. This reduces the public API surface and prevents accidental usage.
// File: UserService.cs namespace MyApp.Services; // File-scoped type - only accessible within this file file class UserValidator { public bool Validate(User user) => !string.IsNullOrEmpty(user.Name); } // Public class that can use the file-scoped type public class UserService { private readonly UserValidator _validator = new(); public bool RegisterUser(User user) { if (!_validator.Validate(user)) return false; // Register user logic return true; } } // File: Utils.cs file static class StringExtensions // Only visible in this file { public static bool IsValidEmail(this string email) => email.Contains('@') && email.Contains('.'); }
Partial classes allow splitting a class, struct, or interface definition across multiple files. This can be useful for separating generated code from hand-written code or dividing large classes by functionality.
// File: Customer.cs public partial class Customer { public int Id { get; set; } public string Name { get; set; } public void Save() { // Implementation } } // File: Customer.Orders.cs public partial class Customer { public List<Order> Orders { get; } = new List<Order>(); public void AddOrder(Order order) { Orders.Add(order); } } // File: Customer.Validation.cs public partial class Customer { public bool Validate() { // Validation logic return !string.IsNullOrEmpty(Name); } }
Access modifiers control the visibility and accessibility of types and type members. Properly applied access modifiers create clear boundaries and enforce encapsulation.
// Access modifiers public class AccessModifierDemo { public int PublicField; // Accessible from anywhere private int _privateField; // Accessible only within the class protected int ProtectedField; // Accessible within the class and derived classes internal int InternalField; // Accessible within the same assembly protected internal int ProtectedInternalField; // Accessible within the same assembly or derived classes private protected int PrivateProtectedField; // Accessible within the same assembly from derived classes }
- public: Use for types and members that form your public API
- internal: Use for types and members that should be available within your assembly but not externally
- private: Use for implementation details inside a class that shouldn't be accessible elsewhere
- protected: Use for members that should be accessible to derived classes for customization
- protected internal: Use when both derived classes and code within the assembly need access
- private protected: Use when derived classes within the same assembly (but not external ones) need access
Properties provide a way to expose fields while adding validation, computed values, or extra logic during access. They're a fundamental part of C# that enables proper encapsulation in object-oriented design.
public class PropertyDemo { // Auto-implemented property public string Name { get; set; } // Property with backing field private int _age; public int Age { get { return _age; } set { _age = value < 0 ? 0 : value; } } // Expression-bodied property (C# 6.0+) public bool IsAdult => Age >= 18; // Property with different access levels public string Email { get; private set; } // Init-only property (C# 9.0+) public string Id { get; init; } // Required property (C# 11.0+) public required string Username { get; set; } // Indexers private string[] _data = new string[10]; public string this[int index] { get => _data[index]; set => _data[index] = value; } public string this[string key] { get => key switch { "first" => _data[0], "last" => _data[^1], // The "Hat" Operator (^) is used as a prefix to count indexes starting from the end of a list. _ => throw new ArgumentException("Invalid key") }; } }
Additional resources:
- C# coding conventions (Microsoft Docs)
- File-scoped namespaces (Microsoft Docs)
- Access modifiers (Microsoft Docs)
- Properties (Microsoft Docs)
If you think the cheatsheet can be improved, please open a PR with any updates and submit any issues. Also, I will continue to improve this, so you should star this repository, too.
- Open a pull request with improvements
- Discuss ideas in issues
- Spread the word
Dr. Milan Milanović - CTO at 3MD and Microsoft MVP for Developer Technologies.