Chuyển tới nội dung chính

Modern C# Features

Definition

Modern C# refers to features introduced from C# 8 onwards that improve expressiveness, safety, and performance. Each version brings incremental enhancements that reduce boilerplate, enable new patterns, and bring C# closer to a "low-ceremony" language while maintaining backward compatibility.

C# 8 Features

Nullable Reference Types

See Nullable Types for full coverage. Enables compile-time null safety:

#nullable enable
string name = "Alice"; // Non-nullable
string? nick = null; // Nullable

Switch Expressions

See Pattern Matching. Expression-based switch with pattern matching:

string Grade(int score) => score switch
{
>= 90 => "A",
>= 80 => "B",
_ => "F"
};

Async Streams (IAsyncEnumerable<T>)

Asynchronous iteration — yield items as they become available:

async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using var reader = new StreamReader(path);
while (await reader.ReadLineAsync() is { } line)
{
yield return line;
}
}

// Consume with await foreach
await foreach (var line in ReadLinesAsync("data.csv"))
{
Console.WriteLine(line);
}

Indices and Ranges

New syntax for indexing and slicing:

int[] numbers = { 0, 1, 2, 3, 4, 5 };

int last = numbers[^1]; // 5 — from end
int[] slice = numbers[1..4]; // [1, 2, 3] — exclusive end
int[] lastTwo = numbers[^2..]; // [4, 5]
int[] all = numbers[..]; // copy of entire array

Using Declarations

No more nesting blocks for disposal:

using var connection = new SqlConnection(connectionString);
using var command = connection.CreateCommand();
// Dispose called automatically at end of scope

Default Interface Members

Interfaces can define method implementations:

public interface ILogger
{
void Log(string message);

void LogWarning(string message) => Log($"[WARN] {message}");
void LogError(string message) => Log($"[ERROR] {message}");
}

C# 9 Features

Records

Immutable reference types with value-based equality:

public record Person(string FirstName, string LastName, int Age);

var p1 = new Person("Alice", "Smith", 30);
var p2 = new Person("Alice", "Smith", 30);

Console.WriteLine(p1 == p2); // True — value equality
var p3 = p1 with { Age = 31 }; // Non-destructive mutation

Init-Only Setters

Properties that can only be set during object initialization:

public class Configuration
{
public string Host { get; init; } = "localhost";
public int Port { get; init; } = 5432;
}

var config = new Configuration { Host = "db.example.com", Port = 3306 };
// config.Host = "other"; // Compile error — init-only

Top-Level Statements

Remove boilerplate for simple programs:

// Program.cs — the entire file
using Microsoft.AspNetCore.Builder;

var app = WebApplication.CreateBuilder().Build();
app.MapGet("/", () => "Hello, World!");
app.Run();

Target-Typed New

Let the compiler infer the type from context:

Person person = new("Alice", "Smith", 30);
List<int> numbers = new() { 1, 2, 3 };
Dictionary<string, int> map = new() { ["a"] = 1 };

Covariant Return Types

Override methods with a more specific return type:

public abstract class Repository
{
public abstract Entity GetById(int id);
}

public class SqlRepository : Repository
{
public override SqlEntity GetById(int id) => // More specific return type
_context.SqlEntities.Find(id);
}

C# 10 Features

Global Usings

Define usings once for the entire project:

// GlobalUsings.cs
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
<!-- Or auto-generate in .csproj -->
<ImplicitUsings>enable</ImplicitUsings>

File-Scoped Namespaces

Reduce indentation — one of the most impactful quality-of-life improvements:

namespace MyApp.Services; // No braces needed

public class UserService
{
// All types in this file belong to MyApp.Services
}

Record Structs

Value-type records with value-based equality:

public record struct Point(double X, double Y);

var a = new Point(1, 2);
var b = new Point(1, 2);
Console.WriteLine(a == b); // True

Constant Interpolated Strings

const string Prefix = "ERROR_";
const string ErrorCode = $"{Prefix}NOT_FOUND"; // Compile-time constant

Extended Property Patterns

Simplified nested property matching:

// C# 9 — nested braces required
if (order is { Customer: { IsVip: true } }) { }

// C# 10 — dot notation
if (order is { Customer.IsVip: true }) { }

C# 11 Features

Raw String Literals

Multi-line strings without escaping — ideal for JSON, SQL, regex:

var json = """
{
"name": "Alice",
"age": 30,
"regex": "C:\\Users\\.*"
}
""";

var query = """
SELECT u.Id, u.Name
FROM Users u
WHERE u.Active = 1
""";

List Patterns

Match elements in arrays and lists:

int[] numbers = { 1, 2, 3, 4, 5 };
var message = numbers switch
{
[] => "Empty",
[var single] => $"One: {single}",
[var a, var b] => $"Two: {a}, {b}",
[var first, .., var last] => $"First: {first}, Last: {last}",
};

Required Members

Force callers to initialize specific properties:

public class User
{
public required string Email { get; init; }
public required string Name { get; init; }
public string? Phone { get; init; }
}

var user = new User { Email = "a@b.com", Name = "Alice" }; // OK
// var bad = new User { Name = "Alice" }; // Error — Email is required

Generic Math Support

Use arithmetic operators in generic code:

T Add<T>(T a, T b) where T : INumber<T> => a + b;

T Sum<T>(params T[] values) where T : INumber<T> =>
values.Aggregate(T.Zero, (acc, v) => acc + v);

Auto-Default Structs

Fields in structs are auto-initialized to their default values:

public struct Measurement
{
public double Value; // Auto-initialized to 0.0
public string Unit; // Auto-initialized to null
}

File-Scoped Types

Types visible only within the file they are declared in:

file class Helper { /* Only visible in this file */ }

C# 12 Features

Primary Constructors for Classes

Parameters declared on the class itself — available throughout:

public class ProductService(IRepository<Product> repo, ILogger<ProductService> logger)
{
public Product? Find(int id)
{
logger.LogInformation("Finding product {Id}", id);
return repo.Find(id);
}
}
Primary constructor parameters are not properties

Unlike records, primary constructor parameters in classes are not automatically exposed as properties. They are captured into the class body. If you need a public property, declare it explicitly:

public class UserController(ILogger logger)
{
public ILogger Logger { get; } = logger; // Explicit property
}

Collection Expressions

Unified syntax for initializing collections:

int[] array = [1, 2, 3];
List<string> list = ["a", "b", "c"];
Span<int> span = [10, 20, 30];
ReadOnlySpan<char> ros = ['h', 'e', 'l', 'l', 'o'];

// Spread elements from another collection
int[] all = [..array, 4, 5]; // [1, 2, 3, 4, 5]

Inline Arrays

High-performance fixed-size arrays without heap allocation:

[System.Runtime.CompilerServices.InlineArray(4)]
public struct FourInts
{
private int _element0;
}

var buffer = new FourInts();
buffer[0] = 10;
buffer[1] = 20;

Alias Any Type

Use using aliases for any type, including tuples and nullable types:

using Point = (double X, double Y);
using UserId = int?;

C# 13 Features

Params Collections

params now works with ReadOnlySpan<T> and List<T>, not just arrays:

void Log(ReadOnlySpan<string> messages)
{
foreach (var msg in messages)
Console.WriteLine(msg);
}

Log("Starting", "Processing", "Done"); // No array allocation

New Lock Object

System.Threading.Lock replaces lock(obj) with better performance:

private readonly Lock _lock = new();

using (_lock.EnterScope())
{
// Critical section — faster than monitor-based lock
}

Partial Properties

Separate declaration from implementation (useful for source generators):

public partial string ConnectionString { get; }

// In generated file:
public partial string ConnectionString => _config.GetConnectionString();

Field Keyword

Access the backing field from a property accessor without declaring it explicitly:

public string Name
{
get => field;
set => field = value?.Trim() ?? throw new ArgumentNullException();
}

Extension Types (Preview)

A new approach to extending types without modifying them:

extension StringExtensions for string
{
public bool IsNullOrEmpty => string.IsNullOrEmpty(this);
public string Capitalize => char.ToUpper(this[0]) + this[1..];
}

Migration Guidance

Adopt modern features incrementally:

  1. Low risk, high impact first — file-scoped namespaces, global usings, target-typed new, using declarations
  2. Add to new code — records, init setters, switch expressions, pattern matching
  3. Enable per file — nullable reference types (#nullable enable), primary constructors
  4. Project-wide changes lastImplicitUsings, Nullable in .csproj
<!-- Recommended .csproj settings for modern C# -->
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

Common Pitfalls

Overusing top-level statements

Top-level statements are great for simple programs and minimal APIs, but they can make large applications harder to navigate. Once a Program.cs grows beyond a few dozen lines, extract classes and methods.

Nullable warnings in legacy code

Enabling <Nullable>enable</Nullable> project-wide on legacy code produces hundreds of warnings. Migrate file by file with #nullable enable and fix warnings before making it a project-level setting. Do not set <TreatWarningsAsErrors> for nullable warnings until migration is complete.

Record vs class

Use record for data-centric types where value equality makes sense (DTOs, value objects, events). Use class for types with identity-based equality (entities, services). Use record struct for small value types.

Key Takeaways

  1. Each C# version adds features that reduce boilerplate and improve safety — adopt incrementally.
  2. Records provide immutable, value-equal data types with minimal syntax.
  3. File-scoped namespaces and global usings dramatically reduce noise in every file.
  4. Nullable reference types catch null bugs at compile time — enable them.
  5. Pattern matching and switch expressions replace verbose branching.
  6. Primary constructors (C# 12) simplify dependency injection in services.
  7. Use LangVersion set to latest to always have access to new features.

Interview Questions

Q: What's new in C# 10? C# 10 introduced global usings, file-scoped namespaces, record structs, constant interpolated strings, extended property patterns, and improved lambda type inference. These are primarily quality-of-life improvements that reduce boilerplate.

Q: What's new in C# 11? C# 11 added raw string literals, list patterns, required members, generic math support (INumber<T>), file-scoped types, and auto-default structs. Raw string literals and required members have the most day-to-day impact.

Q: What's new in C# 12? C# 12 brought primary constructors for classes, collection expressions ([1, 2, 3]), inline arrays, alias any type, and optional params with ReadOnlySpan. Primary constructors and collection expressions are the most widely adopted.

Q: What are records? Records are reference types (or struct types with record struct) that provide value-based equality, non-destructive mutation (with expressions), and built-in formatting. They are ideal for DTOs, value objects, and event types.

Q: What are top-level statements? Top-level statements let you write a C# program without a Program class or Main method. The compiler generates the Main method automatically. They are ideal for minimal APIs and simple console programs.

Q: What is the difference between init and set? init allows a property to be set only during object initialization (constructor or object initializer). After initialization, the property is effectively read-only. set allows the property to be modified at any time. Use init for immutable configuration objects and DTOs.

References