Table of Contents
- Introduction
- What’s New in C# 8.0
- Setup: Enable C# 8 + Nullable
- Read-only Members
- Default Interface Methods
- Pattern Matching Enhancements
- Using Declarations
- Static Local Functions
- Disposable ref structs
- Nullable Reference Types
- Asynchronous Streams
- Asynchronous Disposable
- Indices and Ranges
- Null-coalescing Assignment
- Unmanaged Constructed Types
- Stackalloc in Nested Expressions
- Interpolated Verbatim Strings
- Best Practices & Migration
- Mini Project
- FAQs
- Interview Questions
- MCQ Quiz
- Downloadables
- Glossary (Tooltips)
- To Become Best coder Practice this
1.1 Introduction to C# 8.0 Features
C# 8.0 is a major update that focuses on safer code, cleaner syntax, and modern async programming. It works closely with the .NET runtime and improves how you write everyday application logic. If you build APIs, web apps, desktop apps, or services, these features help you reduce bugs and write more readable code. Many C# 8 features are especially useful when working with async/await, resources, and null values.
ASCII Map: Where C# 8.0 helps
+------------------------+------------------------------+
| Problem in real apps | C# 8.0 feature that helps |
+------------------------+------------------------------+
| NullReferenceException | Nullable reference types |
| Many using {} blocks | Using declarations |
| Streaming data | Async streams (IAsyncEnumerable) |
| Interface evolution | Default interface methods |
| Cleaner slicing | Indices and ranges |
+------------------------+------------------------------+
Example (Simple Idea)
Imagine you are reading rows from a database and you want to process them one-by-one without loading everything in memory. C# 8.0 gives you asynchronous streams to do this safely and efficiently.
Try it Yourself »// C# 8.0 concept preview (we'll learn this in detail later)
public async IAsyncEnumerable<int> GetNumbersAsync()
{
for (int i = 1; i <= 3; i++)
{
await Task.Delay(200);
yield return i;
}
}
1.2 What’s New in C# 8.0 (Quick Overview)
C# 8.0 introduced a set of practical improvements rather than “just syntax sugar”. The biggest themes are: safer null-handling, better async data processing, and cleaner resource usage. It also improves pattern matching so your decision logic becomes shorter and more readable.
| Category | Feature | Why it matters |
|---|---|---|
| Safety | Nullable reference types | Prevents many runtime null crashes by giving compile-time warnings |
| Async | Async streams, async disposable | Process streaming data efficiently; clean up async resources properly |
| Clean code | Using declarations, static local functions | Less nesting, clearer intent, fewer accidental captures |
| Evolution | Default interface methods | Interfaces can add new methods without breaking old implementations |
| Productivity | Indices & ranges | Simple, readable slicing and indexing |
ASCII Timeline idea
C# 7.x ----(pattern matching starts)----> C# 8.0 ----(null safety + async streams)----> Modern C#
| |
switch, tuples nullable refs, IAsyncEnumerable
Example (Before vs After: Slicing)
Indices and ranges make substring/array slicing more readable.
// Before (older style)
int[] a = { 10, 20, 30, 40, 50 };
int[] middle = a.Skip(1).Take(3).ToArray();
// After (C# 8.0 ranges)
int[] b = { 10, 20, 30, 40, 50 };
int[] mid = b[1..4]; // 20,30,40
Try it Yourself »
1.3 Setup: Enable C# 8.0 + Nullable in Your Project
To use C# 8.0 features reliably, your project must target a compatible SDK and set the language version. For the best experience, also enable nullable warnings so the compiler points out risky null usage. In .NET projects, these settings live in your .csproj.
ASCII Flow: Setup Open project | v Edit .csproj | +-- Set LangVersion=8.0 | +-- Enable Nullable warnings v Build and observe warnings
Example (.csproj settings)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<LangVersion>8.0</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Try it Yourself »
2.1 Read-only Members
In C# 8.0, you can mark members of a struct as readonly. This tells the compiler the method/property will not modify the struct state. It improves correctness and can avoid defensive copies when calling methods on readonly structs. This is especially helpful for performance-critical code and immutable value types.
ASCII Idea: Why readonly members matter Readonly struct value | +-- calling non-readonly method may cause a copy | +-- calling readonly method avoids copy and prevents changes
Example (readonly member)
public struct Money
{
public decimal Amount { get; }
public Money(decimal amount) => Amount = amount;
// C# 8.0: readonly member
public readonly string Format() => $@"₹ {Amount:0.00}";
}
// Usage
var m = new Money(199.5m);
Console.WriteLine(m.Format());
Try it Yourself »
2.2 Default Interface Methods
An interface can now include method implementations. This helps when you want to evolve an interface without forcing every existing class to implement new methods. It’s commonly used in libraries/frameworks to add features while maintaining backward compatibility. Think of it like giving “a default behavior” that types can override if needed.
ASCII: Interface evolution Old interface (v1) | +-- Many apps implement it | Add new method in v2 | +-- With default implementation => old apps still compile
Example (default implementation)
public interface ILogger
{
void Log(string message);
// C# 8.0: default interface method
void LogInfo(string message) => Log("INFO: " + message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}
// Usage
ILogger logger = new ConsoleLogger();
logger.LogInfo("Server started");
Try it Yourself »
2.3 Pattern Matching Enhancements
Pattern matching in C# 8.0 becomes more expressive and readable. You can match shapes of objects (properties), tuples, and use switch expressions to reduce bulky switch statements. This is great for validation, mapping, and business rules where you branch based on object data. It makes your code feel more “rule-based” and less nested.
ASCII: switch statement vs switch expression
switch(x) { case ...: return ...; default: return ...; }
becomes
return x switch { pattern => value, _ => value };
Example (switch expression + property pattern)
public record Order(decimal Total, bool IsPremium);
public static string GetDiscountLabel(Order o) => o switch
{
{ IsPremium: true, Total: >= 5000 } => "Premium 20% Discount",
{ IsPremium: true } => "Premium 10% Discount",
{ Total: >= 3000 } => "Regular 5% Discount",
_ => "No Discount"
};
// Usage
Console.WriteLine(GetDiscountLabel(new Order(6000, true)));
Try it Yourself »
3.1 Using Declarations
A using declaration lets you create an object that must be disposed, without wrapping it in a full using { } block. The object will be disposed automatically at the end of the current scope. This reduces indentation and makes “happy path” code easier to read. It relies on the IDisposable pattern.
ASCII: Dispose timing Method scope starts | +-- using var stream = ... | +-- do work | Scope ends => Dispose() called automatically
Example (using var)
using var writer = new StreamWriter("log.txt");
writer.WriteLine("App started");
writer.WriteLine("App finished");
// Dispose happens automatically here (end of scope)
Try it Yourself »
3.2 Static Local Functions
Local functions are methods defined inside another method. In C# 8.0, you can mark them static. A static local function cannot capture variables from the outer method (no hidden closure). This prevents accidental memory allocations and makes intent explicit: the function is self-contained. It’s useful in algorithms and validation helpers inside a method.
ASCII: Capturing vs not capturing Outer method variables | +-- local function (non-static) can capture => closure allocation | +-- local function (static) cannot capture => safer + faster
Example (static local function)
public static bool IsValidEmail(string email)
{
// static local function: cannot capture outer variables
static bool HasAtSymbol(string value) => value.Contains("@");
if (string.IsNullOrWhiteSpace(email)) return false;
return HasAtSymbol(email);
}
Try it Yourself »
3.3 Disposable ref structs
Some types are stack-only (ref struct) for performance and safety. In C# 8.0, these can participate in the using pattern. That means you can write code that ensures cleanup even for stack-only constructs. The cleanup is done using a Dispose() method pattern, typically for performance-sensitive resources. This feature supports modern memory patterns used in parsing and pipelines.
ASCII: Stack-only safety Heap objects => can live long, can be captured ref struct => stack-only, short-lived, safe for spans C# 8 enables: using var x = ref struct; // clean end-of-scope
Example (conceptual pattern)
// Example pattern: a ref struct with Dispose method
public ref struct TempBuffer
{
public void Dispose()
{
// cleanup logic (concept)
}
}
public static void UseBuffer()
{
// C# 8.0: using works with ref struct (pattern-based)
using var buf = new TempBuffer();
// work with buf
}
Try it Yourself »
4.1 Nullable Reference Types
Nullable reference types help you avoid the most common C# production bug: null reference crashes. With nullable enabled, string means “should not be null” and string? means “may be null”. The compiler warns you when you might return, pass, or dereference null. It doesn’t change runtime behavior automatically; it changes how you design and validate your code.
ASCII: Meaning string => expected non-null string? => allowed to be null Compiler => warns if you treat string? like string
Example (safe handling)
#nullable enable
public static string GetDisplayName(string? name)
{
if (name is null) return "Guest";
return name.Trim();
}
// Usage
Console.WriteLine(GetDisplayName(null)); // Guest
Console.WriteLine(GetDisplayName(" Gopi ")); // Gopi
Try it Yourself »
4.2 Null-coalescing Assignment (??=)
The ??= operator assigns a value only when the left-hand side is null. This is perfect for lazy initialization: create something only when you actually need it. It reduces repeated “if (x == null) x = ...” blocks and keeps code cleaner. It works nicely with nullable reference types to make intent obvious.
ASCII: Behavior x ??= value IF x is null => x becomes value ELSE => x stays same
Example (lazy init)
List<string>? logs = null;
// Later...
logs ??= new List<string>();
logs.Add("Started");
Try it Yourself »
5.1 Asynchronous Streams (IAsyncEnumerable<T>)
Asynchronous streams let you return data gradually over time, while still using async. Instead of returning a full list, you yield items one by one using yield return in an async iterator. The caller consumes items with await foreach, which is perfect for streaming APIs and large data processing. This is ideal when data arrives slowly (network, database paging, files, IoT events).
ASCII: Streaming idea Producer (async iterator) Consumer yield item #1 --------------> await foreach handles #1 yield item #2 --------------> await foreach handles #2 ... ... No need to store full list in memory
Example (producer + consumer)
public static async IAsyncEnumerable<string> ReadLinesAsync()
{
// Simulating "lines coming over time"
yield return "Line 1";
await Task.Delay(150);
yield return "Line 2";
await Task.Delay(150);
yield return "Line 3";
}
public static async Task Demo()
{
await foreach (var line in ReadLinesAsync())
{
Console.WriteLine(line);
}
}
Try it Yourself »
5.2 Asynchronous Disposable (IAsyncDisposable)
Some resources require async cleanup (like flushing buffers to the network or finishing an async operation). C# 8.0 introduces await using so you can dispose asynchronously using DisposeAsync(). This prevents blocking threads during cleanup and improves scalability in servers. Use this when the type implements IAsyncDisposable.
ASCII: Cleanup flow await using resource = ... | +-- use resource | scope ends => await resource.DisposeAsync()
Example (custom async disposable)
public class AsyncConnection : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(100); // simulate async cleanup
Console.WriteLine("Async cleanup done");
}
}
public static async Task Demo()
{
await using var conn = new AsyncConnection();
Console.WriteLine("Using connection...");
}
Try it Yourself »
6.1 Indices and Ranges
Indices and ranges make accessing and slicing arrays/strings easier. The ^ operator counts from the end (like “last element”). The .. operator creates a slice (range) between two positions. This improves readability in data processing, parsing, and UI display logic.
ASCII: Index and range a[^1] => last element a[1..4] => elements 1,2,3 (end is exclusive)
Example (slice + last item)
int[] a = { 10, 20, 30, 40, 50 };
int last = a[^1]; // 50
int[] slice = a[1..4];// 20,30,40
Console.WriteLine(last);
Console.WriteLine(string.Join(",", slice));
Try it Yourself »
7.1 Unmanaged Constructed Types
C# supports the unmanaged constraint in generics for types that contain no managed references. In C# 8.0, the rules improved so some constructed generic types can qualify as unmanaged when their type arguments are unmanaged. This matters for interop, low-level memory operations, and high-performance structures. It’s a specialized feature used mainly in advanced scenarios.
ASCII: Idea unmanaged type => no object references inside Examples: int, double, struct of unmanaged fields Benefit: can use stackalloc / unsafe / interop safely
Example (generic constraint)
public struct Point2D
{
public int X;
public int Y;
}
public static int SizeOf<T>() where T : unmanaged
{
// concept: safe to do low-level operations
return sizeof(T);
}
Console.WriteLine(SizeOf<Point2D>());
Try it Yourself »
7.2 Stackalloc in Nested Expressions
stackalloc allocates memory on the stack for very fast temporary buffers. C# 8.0 improved where stackalloc can be used, including in more nested expressions. This helps in high-performance parsing and small temporary allocations. It is an advanced feature and should be used carefully to avoid stack overflows.
ASCII: Why stackalloc? Heap allocation => slower + garbage collection later Stack allocation => very fast + freed automatically at scope end
Example (temporary buffer)
// Advanced concept: stackalloc buffer (simplified example)
Span<int> buf = stackalloc int[3];
buf[0] = 10;
buf[1] = 20;
buf[2] = 30;
Console.WriteLine(buf[1]);
Try it Yourself »
7.3 Enhancement of Interpolated Verbatim Strings
C# supports interpolated strings ($"...") and verbatim strings (@"..."). C# 8.0 improved how you can combine them and use them in more convenient ways. This is useful when building file paths, templates, or multi-line strings with variables. It reduces escaping pain and improves readability in logging and UI templates.
ASCII: String styles
"Normal" => escape needed for backslash
@"Verbatim" => fewer escapes, better for paths
$"Interpolated" => embed variables with { }
$@"Both" => paths/templates + variables
Example (path template)
string user = "Gopi";
string folder = $@"C:\Users\{user}\Documents\Reports";
Console.WriteLine(folder);
Try it Yourself »
8. Best Practices & Migration Checklist
When upgrading to C# 8.0, focus on features that increase safety and readability. Start with nullable reference types (biggest bug reduction) and using declarations (cleaner resource usage). Then add async streams for streaming workloads and pattern matching for business rules. Finally, adopt advanced features only when you truly need performance or interop improvements.
ASCII: Migration order (recommended) 1) Enable C# 8 + build 2) Enable Nullable (module-by-module) 3) Convert using blocks to using declarations where safe 4) Refactor decision logic with switch expressions 5) Introduce async streams for large/streaming data 6) Evaluate advanced memory features if performance needed
Checklist Table
| Step | Action | Success signal |
|---|---|---|
| 1 | Set LangVersion and Nullable in csproj | Build shows meaningful warnings |
| 2 | Fix nullable warnings in DTOs/models first | API boundaries become clear |
| 3 | Replace deep using blocks with using declarations | Less nesting, same behavior |
| 4 | Use switch expressions for mapping/validation | Smaller, rule-based code |
| 5 | Add await foreach streaming where needed | Lower memory usage |
9. Mini Project (Practice Scenario)
Build a “Log Processing Utility” that reads lines from a source, filters them, and writes a summary report. Use async streams to process logs line-by-line, using declarations to handle files, and nullable references to validate input. Add pattern matching to classify log entries as Error/Warning/Info. This mini project connects multiple C# 8.0 features in one realistic workflow.
ASCII: Data Flow Log Source (stream) | v await foreach (async stream) Filter + classify (pattern matching) | v Write report (using declarations) | v Output summary
Starter Skeleton (C#)
#nullable enable
public static class LogProcessor
{
public static async Task RunAsync(string? inputPath)
{
if (string.IsNullOrWhiteSpace(inputPath))
{
Console.WriteLine("Input path is required.");
return;
}
using var writer = new StreamWriter("summary.txt");
await foreach (var line in ReadLogLinesAsync(inputPath))
{
// classify with pattern matching (exercise)
await writer.WriteLineAsync(line);
}
}
public static async IAsyncEnumerable<string> ReadLogLinesAsync(string path)
{
using var reader = new StreamReader(path);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line != null) yield return line;
}
}
}
Try it Yourself »
10. FAQs
| Question | Answer |
|---|---|
| Does nullable reference types change runtime behavior? | No. It mainly adds compile-time warnings and changes how you design types (string vs string?). |
| Should I enable nullable in an old project? | Yes, but do it module-by-module. Start with DTOs and public APIs. |
| When should I use async streams? | When data arrives over time or is very large (logs, network, paging from DB). |
| Are default interface methods common in app code? | More common in libraries/frameworks. Use them carefully in app code. |
11. Interview Questions
- What problem do nullable reference types solve, and how do they work?
- Explain using declarations vs using blocks. When would you prefer each?
- What is IAsyncEnumerable<T> and how do you consume it?
- What is IAsyncDisposable and why is await using needed?
- How do default interface methods help library versioning?
- Explain indices (^) and ranges (..) with an example.
- What is a ref struct and why is it stack-only?
- How do static local functions improve performance and safety?
- Give a real-world example of pattern matching in business rules.
- What is the risk of overusing the null-forgiving operator (!) ?
12. MCQ Quiz (C# 8.0 Features)
Choose the best answer. (Answers are provided after the questions so learners can self-check.)
Questions
- Nullable reference types mainly provide:
- A) Runtime exceptions for null
- B) Compile-time warnings for risky null usage
- C) Automatic null conversion at runtime
- D) Removal of null from C#
- Which syntax is used to dispose asynchronously?
- A) using var
- B) await using
- C) dispose using
- D) async using()
- What does a[^1] return?
- A) First item
- B) Second item
- C) Last item
- D) Middle item
- Using declarations dispose resources:
- A) Immediately after creation
- B) At end of current scope
- C) Only when GC runs
- D) Only if you call Dispose manually
- Static local functions:
- A) Can capture outer variables freely
- B) Cannot capture outer variables
- C) Always run faster than normal methods
- D) Are only for async methods
- IAsyncEnumerable<T> is commonly consumed using:
- A) foreach
- B) await foreach
- C) for loop only
- D) while(true)
- Default interface methods are especially useful for:
- A) Reducing class file size
- B) Evolving interfaces without breaking implementations
- C) Replacing inheritance
- D) Removing interfaces
Answer Key
- 1) B
- 2) B
- 3) C
- 4) B
- 5) B
- 6) B
- 7) B
13. Downloadables (ZIP / PPT / DOC Placeholder)
This section is a placeholder for your course assets. In your ItTechGenie site, you can attach: ZIP for sample projects, PPT for classroom slides, and DOC for printable notes.
Placeholder Blocks
- ZIP: CSharp8_Samples.zip (source code, exercises)
- PPT: CSharp8_Features_Slides.pptx (training deck)
- DOC: CSharp8_Notes.docx (printable notes)
14. Glossary (Tooltips)
These are the key tooltip terms used in this page. In your lessons, encourage learners to hover and read slowly.
| Term | Tooltip Description |
|---|---|
| C# 8.0 | A C# language version that introduced safer null-handling, async streams, and cleaner resource management. |
| .NET runtime | The environment that runs .NET apps, manages memory, loads assemblies, and executes code securely. |
| async/await | A non-blocking programming style. async/await helps write asynchronous code that reads like normal code. |
| .csproj | A project configuration file that controls target framework, language version, nullable, and build behavior. |
| struct | A value type copied by value, often used for small immutable data like points, money, measurements. |
| interface | A contract defining members a type must implement; C# 8 also allows default implementations in interfaces. |
| IDisposable | An interface for deterministic cleanup of resources (files, connections). Used by using declarations/blocks. |
| ref struct | A stack-only struct used in high-performance memory scenarios. Cannot be boxed or captured. |
| ??= | Assigns a value only if the left side is null, useful for lazy initialization and defaults. |
| IAsyncEnumerable<T> | An async sequence consumed via await foreach; yields items over time without loading everything in memory. |
| IAsyncDisposable | Async cleanup pattern using DisposeAsync; used with await using for non-blocking disposal. |
To Become Best coder Practice this
Below are 15 practical coding questions based on C# 8.0 features. Mix of Easy/Medium/Hard. Each includes a problem statement, I/O format, example, constraints, and a short hint (no full solution).
Easy (1–5)
-
Nullable Display Name
Problem: Write a method that returns "Guest" when the input name is null/empty; otherwise returns trimmed name.
I/O: Input: string? name → Output: string
Example: Input: null → Output: Guest
Constraints: name length ≤ 200
Hint: Use nullable reference types and a null check. -
Lazy Initialize List
Problem: If a List<string>? is null, initialize it and add a message.
I/O: Input: List<string>? logs, string msg → Output: List<string>
Example: Input: null,"Hi" → Output: ["Hi"]
Constraints: msg length ≤ 100
Hint: Use ??= to initialize. -
Last Element Finder
Problem: Return the last element of an int array using indices (^).
I/O: Input: int[] a → Output: int
Example: [5,7,9] → 9
Constraints: array length ≥ 1
Hint: Use a[^1]. -
Middle Slice
Problem: Return a slice of an array from index 1 to index 3 (end exclusive).
I/O: Input: int[] a → Output: int[]
Example: [10,20,30,40,50] → [20,30,40]
Constraints: length ≥ 4
Hint: Use ranges: a[1..4]. -
Using Declaration File Write
Problem: Write two lines to a file using using declaration (no using block).
I/O: Input: string path → Output: file created/updated
Example: path="out.txt" → file contains 2 lines
Constraints: valid path
Hint: using var writer = new StreamWriter(path);
Medium (6–10)
-
Order Discount Rule Engine
Problem: Use switch expression + property patterns to return discount labels based on Total and IsPremium.
I/O: Input: (decimal Total, bool IsPremium) → Output: string
Example: (6000,true) → "Premium 20% Discount"
Constraints: Total ≥ 0
Hint: Use record + switch expression with property patterns. -
Static Local Validator
Problem: Write a method that validates a product code using a static local function (no captures).
I/O: Input: string code → Output: bool
Example: "PRD-1001" → true
Constraints: code length ≤ 50
Hint: Put regex/format checks into static local function. -
Async Stream Number Generator
Problem: Create an async iterator that yields numbers 1..N with delay, then consume using await foreach.
I/O: Input: int N → Output: prints numbers
Example: N=3 → prints 1,2,3
Constraints: 1 ≤ N ≤ 1000
Hint: async IAsyncEnumerable + yield return + Task.Delay. -
Null-safe Customer Mapper
Problem: Map a DTO to a domain model ensuring nullable fields are handled safely (no warnings).
I/O: Input: CustomerDto → Output: Customer
Example: dto.Name=null → model.Name="Unknown"
Constraints: keep model non-nullable
Hint: Use ?? and null checks; avoid overusing !. -
Path Template Builder
Problem: Build a multi-line report header using interpolated verbatim string $@"...".
I/O: Input: string user, DateTime date → Output: string header
Example: user=Gopi → includes user line
Constraints: header ≤ 2000 chars
Hint: Use $@"Line1{var}\nLine2".
Hard (11–15)
-
Async Log Processor with Filtering
Problem: Read a huge log file using async streams, filter only ERROR lines, write them to a new file using using declarations.
I/O: Input: inputPath, outputPath → Output: filtered file
Example: "app.log" → "errors.log" contains only ERROR lines
Constraints: file can be very large
Hint: async iterator yields lines; consumer writes only matching lines. -
Async Disposable Wrapper
Problem: Create a custom resource that buffers data and flushes asynchronously on DisposeAsync; use await using in a demo.
I/O: Input: list of messages → Output: flush confirmation
Example: ["a","b"] → prints "Flushed 2 items"
Constraints: must not block thread in cleanup
Hint: Implement IAsyncDisposable with ValueTask DisposeAsync(). -
Nullable-Aware API Response Builder
Problem: Build an API response object where optional fields are nullable, but required fields are non-nullable. Ensure zero nullable warnings.
I/O: Input: domain model with optional values → Output: response DTO
Example: optional description null → response.Description=null
Constraints: no suppression operator (!) allowed except rare cases
Hint: Design DTO with string? only where needed; validate required fields. -
Rule Engine with Pattern Matching + Ranges
Problem: Given an array of daily sales, classify it: - If last 3 days are all >= 1000 → "Hot" - If last day < 200 → "Cold" - else "Normal"
I/O: Input: int[] sales → Output: string
Example: [500,1200,1300,1500] → Hot
Constraints: length ≥ 3
Hint: Use sales[^3..] to slice last 3, then pattern/conditions. -
High-performance Parser (Stackalloc + Span)
Problem: Parse a 3-digit code from a string without allocations using Span and a small stackalloc buffer (conceptual).
I/O: Input: string s like "ID:123" → Output: int 123
Example: "ID:123" → 123
Constraints: input format guaranteed; keep allocations minimal
Hint: Use Spanand stackalloc for temp; convert digits manually.