Md Mominul Islam | Software and Data Enginnering | SQL Server, .NET, Power BI, Azure Blog

while(!(succeed=try()));

LinkedIn Portfolio Banner

Latest

Home Top Ad

Responsive Ads Here

Sunday, August 17, 2025

Master Modern C# Features: From C# 9 to C# 12 – Module 9 of Complete C# Course

 

Introduction

Welcome to Module 9 of our Complete C# Course: From Beginner to Advanced! After mastering asynchronous and parallel programming in Module 8, it’s time to explore modern C# features introduced from C# 9 to C# 12. These features make code more concise, expressive, and safe. In this module, we’ll cover nullable reference types, pattern matching enhancements, switch expressions, top-level statements, records and init-only setters, primary constructors, interpolated string handlers, default interface methods, and collection expressions. We’ll apply these in a practical task management system for a productivity app, inspired by real-world tools like Todoist or Trello. With detailed examples, best practices, and pros/cons, you’ll learn to leverage C#’s latest capabilities. Let’s dive in!


1. Nullable Reference Types

Nullable reference types help prevent null reference exceptions by enforcing null-safety at compile time.

Example: Safe Task Assignment

#nullable enable
using System;

namespace TaskSystem
{
    class Task
    {
        public string? Title { get; set; } // Nullable
        public string AssignedTo { get; set; } = string.Empty; // Non-nullable

        public void Display()
        {
            Console.WriteLine($"Task: {Title ?? "Untitled"}, Assigned: {AssignedTo}");
        }
    }

    class Program
    {
        static void Main()
        {
            Task task = new Task { Title = null, AssignedTo = "Alice" };
            task.Display(); // Task: Untitled, Assigned: Alice
            // task.AssignedTo = null; // Compile error: Cannot assign null
        }
    }
}
  • Real-World Use: Ensuring user inputs or API responses aren’t null in task management apps.

  • Pros:

    • Catches null errors at compile time.

    • Improves code safety and maintainability.

  • Cons:

    • Requires enabling with #nullable enable or project-wide settings.

    • Can produce warnings in legacy codebases.

  • Best Practices:

    • Enable nullable reference types in new projects (<Nullable>enable</Nullable>).

    • Use ? for nullable references; initialize non-nullable fields.

    • Handle nulls explicitly with ?? or null checks.

  • Alternatives:

    • Manual null checks (less safe).

    • Option/Maybe types from libraries.


2. Pattern Matching Enhancements

Pattern matching (C# 9+) simplifies conditional logic with is, switch, and relational patterns.

Example: Task Status Check

using System;

namespace TaskSystem
{
    class Task
    {
        public string Title { get; set; }
        public int Priority { get; set; }
    }

    class Program
    {
        static string GetPriorityLevel(Task task) => task switch
        {
            { Priority: >= 8 } => "High",
            { Priority: >= 5 } => "Medium",
            { Priority: > 0 } => "Low",
            _ => "Undefined"
        };

        static void Main()
        {
            Task task = new Task { Title = "Finish Report", Priority = 7 };
            Console.WriteLine($"{task.Title}: {GetPriorityLevel(task)}"); // Finish Report: Medium
        }
    }
}
  • Real-World Use: Categorizing tasks or validating user inputs.

  • Pros:

    • Concise and expressive conditionals.

    • Combines type checking and property evaluation.

  • Cons:

    • Complex patterns can reduce readability.

    • Limited to C# 9+.

  • Best Practices:

    • Use property patterns for simple checks.

    • Combine with switch expressions for clarity.

    • Avoid overly complex patterns.

  • Alternatives:

    • Traditional if/else statements.

    • Dictionary-based lookups for static mappings.


3. Switch Expressions

Switch expressions (C# 8+, enhanced in 9+) provide a concise way to map values to results.

Example: Task Status Mapping

using System;

namespace TaskSystem
{
    enum TaskStatus { Pending, InProgress, Completed }

    class Task
    {
        public string Title { get; set; }
        public TaskStatus Status { get; set; }
    }

    class Program
    {
        static string GetStatusDescription(Task task) => task.Status switch
        {
            TaskStatus.Pending => "Not started",
            TaskStatus.InProgress => "Work in progress",
            TaskStatus.Completed => "Done",
            _ => throw new ArgumentException("Invalid status")
        };

        static void Main()
        {
            Task task = new Task { Title = "Write Code", Status = TaskStatus.InProgress };
            Console.WriteLine($"{task.Title}: {GetStatusDescription(task)}"); // Write Code: Work in progress
        }
    }
}
  • Real-World Use: Mapping task states or user roles in workflows.

  • Pros:

    • More concise than traditional switch statements.

    • Ensures exhaustive cases with _ or exceptions.

  • Cons:

    • Requires C# 8+.

    • Less flexible for complex logic.

  • Best Practices:

    • Use for simple value-to-result mappings.

    • Include _ for default cases or throw exceptions.

    • Combine with pattern matching for advanced logic.

  • Alternatives:

    • Traditional switch statements.

    • Dictionary-based mappings.


4. Top-level Statements

Top-level statements (C# 9+) simplify program structure by removing the need for Main boilerplate.

Example: Simple Task App

using System;

Console.WriteLine("Welcome to Task App!");
Console.Write("Enter task title: ");
string title = Console.ReadLine()!;
Console.WriteLine($"Task added: {title}");
  • Real-World Use: Quick prototyping or simple console apps like task trackers.

  • Pros:

    • Reduces boilerplate for small programs.

    • Ideal for scripting or learning.

  • Cons:

    • Not suitable for complex applications.

    • Limited to one file per project.

  • Best Practices:

    • Use for simple apps or demos.

    • Avoid in large projects with multiple files.

    • Combine with other modern features for clarity.

  • Alternatives:

    • Traditional Program class with Main.

    • Scripts in other languages (e.g., Python).


5. Records and Init-only Setters

Records (C# 9+) provide immutable data types, and init-only setters ensure properties are set only during initialization.

Example: Immutable Task Record

using System;

namespace TaskSystem
{
    public record Task(string Title, int Priority)
    {
        public bool IsCompleted { get; init; } // Init-only setter
    }

    class Program
    {
        static void Main()
        {
            Task task1 = new Task("Write Code", 5) { IsCompleted = true };
            Task task2 = task1 with { Priority = 7 };
            Console.WriteLine($"Task1: {task1}, Task2: {task2}");
            // Task1: Task { Title = Write Code, Priority = 5, IsCompleted = True }
            // Task2: Task { Title = Write Code, Priority = 7, IsCompleted = True }
        }
    }
}
  • Real-World Use: Storing immutable task or user data in productivity apps.

  • Pros:

    • Records simplify immutable data with value equality.

    • Init-only setters ensure immutability post-construction.

  • Cons:

    • Records are less flexible for mutable state.

    • C# 9+ requirement limits compatibility.

  • Best Practices:

    • Use records for DTOs or immutable data.

    • Use with expressions for non-destructive updates.

    • Combine with init-only setters for controlled initialization.

  • Alternatives:

    • Classes with manual immutability.

    • Structs for lightweight data.


6. Primary Constructors

Primary constructors (C# 12) simplify class/record initialization by integrating constructor parameters into the type declaration.

Example: Task with Primary Constructor

using System;

namespace TaskSystem
{
    public record Task(string Title, int Priority)
    {
        public bool IsCompleted { get; init; }

        public string GetDetails() => $"{Title}: Priority {Priority}, {(IsCompleted ? "Done" : "Pending")}";
    }

    class Program
    {
        static void Main()
        {
            Task task = new Task("Review Code", 8) { IsCompleted = true };
            Console.WriteLine(task.GetDetails()); // Review Code: Priority 8, Done
        }
    }
}
  • Real-World Use: Defining concise data models for tasks or orders.

  • Pros:

    • Reduces constructor boilerplate.

    • Integrates seamlessly with records.

  • Cons:

    • C# 12 requirement.

    • Less flexible for complex constructor logic.

  • Best Practices:

    • Use for simple initialization scenarios.

    • Combine with init-only setters for immutability.

    • Avoid for classes requiring complex setup.

  • Alternatives:

    • Traditional constructors.

    • Factory methods for complex initialization.


7. Interpolated String Handlers

Interpolated string handlers (C# 10) optimize string interpolation performance by building strings incrementally.

Example: Task Report

using System;
using System.Text;

namespace TaskSystem
{
    class Task
    {
        public string Title { get; set; }
        public int Priority { get; set; }

        public string GetReport()
        {
            StringBuilder builder = new StringBuilder();
            var handler = new DefaultInterpolatedStringHandler();
            handler.AppendLiteral("Task: ");
            handler.AppendFormatted(Title);
            handler.AppendLiteral(", Priority: ");
            handler.AppendFormatted(Priority);
            return handler.ToStringAndClear();
        }
    }

    class Program
    {
        static void Main()
        {
            Task task = new Task { Title = "Debug App", Priority = 6 };
            Console.WriteLine(task.GetReport()); // Task: Debug App, Priority: 6
        }
    }
}
  • Real-World Use: Generating efficient reports or logs in task apps.

  • Pros:

    • Reduces memory allocations for complex interpolations.

    • Fine-grained control over string building.

  • Cons:

    • More verbose than standard interpolation.

    • C# 10+ requirement.

  • Best Practices:

    • Use for performance-critical string building.

    • Combine with StringBuilder for large strings.

    • Use standard interpolation for simple cases.

  • Alternatives:

    • Standard string interpolation ($"...").

    • StringBuilder for manual string construction.


8. Default Interface Methods

Default interface methods (C# 8+) allow interfaces to provide method implementations.

Example: Task Notifications

using System;

namespace TaskSystem
{
    interface INotifiable
    {
        void Notify(string message) => Console.WriteLine($"Default Notification: {message}");
    }

    class Task : INotifiable
    {
        public string Title { get; set; }
        public void Complete() => Notify($"{Title} completed!");
    }

    class CustomTask : INotifiable
    {
        public string Title { get; set; }
        public void Notify(string message) => Console.WriteLine($"Custom Notification: {message}");
        public void Complete() => Notify($"{Title} completed!");
    }

    class Program
    {
        static void Main()
        {
            Task task = new Task { Title = "Write Code" };
            task.Complete(); // Default Notification: Write Code completed!

            CustomTask customTask = new CustomTask { Title = "Test App" };
            customTask.Complete(); // Custom Notification: Test App completed!
        }
    }
}
  • Real-World Use: Adding default behavior to plugins or task workflows.

  • Pros:

    • Allows backward-compatible interface evolution.

    • Reduces boilerplate in implementing classes.

  • Cons:

    • Can lead to complex interface designs.

    • C# 8+ requirement.

  • Best Practices:

    • Use for optional or default behavior.

    • Keep interfaces focused; avoid overusing defaults.

    • Document default implementations clearly.

  • Alternatives:

    • Abstract classes for shared implementations.

    • Extension methods for external behavior.


9. Collection Expressions

Collection expressions (C# 12) provide a concise syntax for initializing collections.

Example: Task List Initialization

using System;
using System.Collections.Generic;

namespace TaskSystem
{
    record Task(string Title, int Priority);

    class Program
    {
        static void Main()
        {
            List<Task> tasks = [new Task("Write Code", 5), new Task("Test App", 8)];
            int[] priorities = [5, 8, 3];

            Console.WriteLine("Tasks:");
            foreach (var task in tasks)
            {
                Console.WriteLine($"{task.Title}: {task.Priority}");
            }

            Console.WriteLine($"Average Priority: {priorities.Average()}");
        }
    }
}
  • Real-World Use: Initializing task lists or user roles in productivity apps.

  • Pros:

    • Concise and readable collection initialization.

    • Works with arrays, lists, and spans.

  • Cons:

    • C# 12 requirement.

    • Limited to initialization scenarios.

  • Best Practices:

    • Use for simple collection initialization.

    • Combine with LINQ for further processing.

    • Avoid for dynamic or complex collection creation.

  • Alternatives:

    • Traditional new List<T> { ... } syntax.

    • Array/list initializers.


Interactive Example: Task Management System

Let’s build a console-based task management system to apply modern C# features.

// File: Models/Task.cs
namespace TaskSystem.Models
{
    public enum TaskStatus { Pending, InProgress, Completed }

    public record Task(string Title, int Priority, TaskStatus Status = TaskStatus.Pending)
    {
        public bool IsCompleted { get; init; }
        public string GetDetails() => $"{Title}: Priority {Priority}, {Status}";
    }
}

// File: Services/TaskManager.cs
namespace TaskSystem.Services
{
    interface INotifiable
    {
        void Notify(string message) => Console.WriteLine($"Notification: {message}");
    }

    class TaskManager : INotifiable
    {
        private List<Models.Task> tasks = [ ];

        public void AddTask(string title, int priority)
        {
            var task = new Models.Task(title, priority) { IsCompleted = false };
            tasks.Add(task);
            Notify($"Task {title} added.");
        }

        public void UpdateStatus(string title, Models.TaskStatus status)
        {
            var task = tasks.Find(t => t.Title == title);
            if (task is null)
            {
                Notify($"Task {title} not found.");
                return;
            }
            tasks[tasks.IndexOf(task)] = task with { Status = status };
            Notify($"Task {title} updated to {status}.");
        }

        public string GetSummary()
        {
            var handler = new DefaultInterpolatedStringHandler();
            handler.AppendLiteral("Task Summary:\n");
            foreach (var task in tasks)
            {
                handler.AppendFormatted(
                    task switch
                    {
                        { Priority: >= 8 } => $"{task.Title}: High Priority",
                        { Priority: >= 5 } => $"{task.Title}: Medium Priority",
                        _ => $"{task.Title}: Low Priority"
                    });
                handler.AppendLiteral("\n");
            }
            return handler.ToStringAndClear();
        }
    }
}

// File: Program.cs
using System;
using TaskSystem.Models;
using TaskSystem.Services;

Console.WriteLine("Task Management System");
TaskManager manager = new TaskManager();

while (true)
{
    Console.WriteLine("\n1. Add Task");
    Console.WriteLine("2. Update Task Status");
    Console.WriteLine("3. View Summary");
    Console.WriteLine("4. Exit");
    Console.Write("Choose an option: ");

    string? choice = Console.ReadLine();
    if (choice == "4") break;

    switch (choice)
    {
        case "1":
            Console.Write("Enter task title: ");
            string? title = Console.ReadLine();
            Console.Write("Enter priority (1-10): ");
            if (!int.TryParse(Console.ReadLine(), out int priority) || priority < 1 || priority > 10)
            {
                Console.WriteLine("Invalid priority!");
                continue;
            }
            manager.AddTask(title ?? "Untitled", priority);
            break;

        case "2":
            Console.Write("Enter task title: ");
            title = Console.ReadLine();
            Console.Write("Enter status (Pending/InProgress/Completed): ");
            string? statusInput = Console.ReadLine();
            if (!Enum.TryParse<TaskStatus>(statusInput, true, out var status))
            {
                Console.WriteLine("Invalid status!");
                continue;
            }
            manager.UpdateStatus(title ?? "", status);
            break;

        case "3":
            Console.WriteLine(manager.GetSummary());
            break;

        default:
            Console.WriteLine("Invalid option!");
            break;
    }
}
  • How It Works:

    • Nullable Reference Types: Ensures title and other inputs are checked for null.

    • Pattern Matching: Used in GetSummary to categorize tasks by priority.

    • Switch Expressions: Maps priorities to descriptions.

    • Top-level Statements: Simplifies the Program.cs entry point.

    • Records/Init-only Setters: Task record with immutable properties.

    • Primary Constructors: Used in Task for concise initialization.

    • Interpolated String Handlers: Optimizes summary generation.

    • Default Interface Methods: INotifiable provides default notification behavior.

    • Collection Expressions: Initializes tasks list concisely.

  • Why It’s Useful: Mimics productivity apps for managing tasks or projects.

  • Setup: Create a new Console App in Visual Studio or run dotnet new console -langVersion 12. Organize files as shown. Requires .NET 6+ for most features, .NET 8 for collection expressions.


Best Standards for Module 9

  • Nullable Reference Types: Enable project-wide; use ? for nullable types.

  • Pattern Matching: Use for concise conditionals; avoid over-complex patterns.

  • Switch Expressions: Use for value mappings; ensure exhaustive cases.

  • Top-level Statements: Use for simple apps; avoid in complex projects.

  • Records/Init-only Setters: Use for immutable data; combine with with expressions.

  • Primary Constructors: Use for concise initialization; avoid complex logic.

  • Interpolated String Handlers: Use for performance-critical string building.

  • Default Interface Methods: Use for optional behavior; keep interfaces focused.

  • Collection Expressions: Use for concise initialization; combine with LINQ.


Conclusion

You’ve just mastered modern C# features from C# 9 to C# 12! By learning nullable reference types, pattern matching, switch expressions, top-level statements, records, primary constructors, interpolated string handlers, default interface methods, and collection expressions, you’re equipped to write concise, safe, and expressive code. The task management system showcases how these features enhance real-world applications.

No comments:

Post a Comment

Thanks for your valuable comment...........
Md. Mominul Islam