C# 15
C# 15 includes the following new features. Try these features by using the latest Visual Studio 2026 insiders version or the .NET 11 preview SDK: Collection expression arguments Union types Closed hierarchies Extension indexers Labeled break and continue Memory safety C# 15 is the latest C# preview release. .NET 11 preview versions support C# 15. For more information, see C# language versioning.
SPACED REPETITION Β· 15 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 15Review pending: Fact-checking continues in the background. Check the review status before relying on this lesson.
C# 15 in the Evolution Line: Turning On Preview Features and Deciding What to Adopt
Upgrading .NET trains a comfortable reflex: change one line in the project file, rebuild, everything still compiles. That reflex breaks the first time a teammate's machine accepts code yours rejects β same commit, same repository, different compiler. The cause is rarely the runtime. It's language versioning: the compiler ships with rules it applies only if you ask for them, and a preview release is a rule set you switch on deliberately.
C# 15's features aren't variations on one theme, so "should I adopt C# 15?" is the wrong question. Some features change what types you can express, some change how you write a loop, some move where unsafe must appear. Below: how the versioning model works, how to enable preview, and the three buckets that turn adoption into a per-feature decision.
The compiler applies the rule set you select
LangVersion is the compiler option β surfaced as an MSBuild property β that selects which language rules the compiler enforces. A numbered value such as 14 pins stable rules: syntax rejected under 14 stays rejected, and syntax accepted under 14 keeps its meaning. The value preview opts into unreleased features: syntax the current preview compiler understands but that no version number has fixed yet.
The difference is not cosmetic. Under a pinned version the language behaves like a specification you can rely on; under preview, the SDK on the build machine defines the rules, so two developers on different .NET 11 preview builds can genuinely disagree about whether a file compiles. preview is a moving target you opt into, not a stable target you upgrade into.
One piece of bookkeeping belongs to this same model: the compiler can record which safety rules an assembly was built with, via System.Runtime.CompilerServices.MemorySafetyRulesAttribute. Because that rule set is stamped on the binary, downstream tooling can reason about it without re-reading your source β which is why memory-safety adoption (Section 5) is a per-assembly decision rather than a per-file one.
Enabling it, and proving you did
In a project file:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
A file-based program β a single .cs file you run directly without a project β sets the same option with a directive on the first line:
#:property LangVersion=preview
Don't trust the switch; test it. Compile one construct that only parses under preview, both ways. Labeled break is a clean probe:
outer: for (int row = 0; row < grid.Height; row++)
{
for (int column = 0; column < grid.Width; column++)
{
if (grid[row, column].IsBlocked)
{
continue outer; // start the next row
}
if (grid[row, column].IsGoal)
{
break outer; // leave both loops
}
}
}
With <LangVersion>14</LangVersion> the build fails:
error CS8652: Feature 'labeled break and continue' is not available in C# 14.0.
Please use language version 'preview' or greater.
The CS8652 diagnostic is the compiler's standard "this feature sits behind a language version" message. The quoted feature name is the compiler's internal label and varies by construct; the code and the two version strings are what you match on. Restore preview, rebuild, and the diagnostic disappears β that is your proof the option took effect. Getting the opposite error (the feature isn't recognized at all) means the SDK predates it. And note AllowUnsafeBlocks is still required for pointer-related preview syntax such as unsafe(expression), not only for classic unsafe blocks.
Sort the feature before you adopt it
C# 15's features fall into three adoption buckets. The bucket, not the novelty, tells you how much homework each one costs.
| Bucket | C# 15 members | Who sees the change |
|---|---|---|
| Modeling | union, closed |
Anyone using the type |
| Ergonomics | labeled break/continue, extension indexers |
Mostly your own code |
| Safety boundary | pointer relaxations, unsafe(expr), safe |
Callers of unsafe members |
Modeling features change what a type is. union Pet(Cat, Dog, Bird) and the closed modifier reshape a type hierarchy, so every consumer β other assemblies, serializers, test doubles β sees it. Adoption cost is highest because your public surface is the change. (Section 2 expresses a state hierarchy in each form.)
Ergonomics features change how you write code without changing any signature. Labeled break/continue is invisible to callers. Extension indexers are usually internal, with a caveat worth stating: an extension indexer declared in a public static class is visible to everyone who can see that class, so "ergonomics" doesn't automatically mean private. Section 3 covers the receiver parameter rule and the hidden complexity cost of bracket syntax.
Safety boundary features move an audit obligation. Marking a member unsafe makes it requires-unsafe: the obligation flows to the caller, who must itself be in an unsafe context. The pointer relaxations remove unsafe from some operations; unsafe(expression) and the safe contextual keyword let you place it more precisely. All three rewrite your contract with callers β adopt these last and document them most. Section 5 traces the split.
A useful heuristic, not a compiler rule: risk scales with visibility. Invisible to callers β adopt early behind a flag. Present in a public signature or in a caller obligation β wait for the version to stabilize.
Trace: does that default arm still earn its keep?
abstract record class GateState;
sealed record class Closed : GateState;
sealed record class Open(float Percent) : GateState;
string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
_ => "unknown",
};
Does any C# 15 feature let you delete _ here? Work it out before reading on.
Check your answer
No. As written, GateState is an open abstraction: the compiler cannot know the complete set of derived types, so a catch-all arm is required. union and closed are exactly the two features that make exhaustiveness provable β a union fixes its case types, a closed class fixes its direct descendants at compile time.
Rewrite it as public closed record class GateState; with direct descendants Closed and Open, and the _ arm becomes unnecessary; the compiler accepts the switch with no default.
Now the second half: why does adding a derived type change the answer? Because exhaustiveness is derived from the known descendant set. Add public record class Faulted : GateState; inside the declaring assembly and the switch no longer covers every direct descendant, so the compiler flags it as non-exhaustive β the same family of diagnostic you'd get today from a missing enum arm. That is the feature's real value: the omission becomes a build error instead of a silent "unknown" at runtime. Adding the descendant from another assembly is impossible β a closed class forbids derivation outside the declaring assembly.
(This is a simplified view β intermediate descendants that aren't themselves closed remain extensible elsewhere, as Section 2 covers.)
Cost: preview is a moving contract
Reshaping and withdrawal. Preview features can be reshaped or withdrawn before release, and partial implementations are normal. C# 15's union support is a live example: the runtime types UnionAttribute and IUnion exist while parts of the proposal remain unimplemented. Code that compiles today may need edits after a compiler update.
Contract leakage. A public method returning a preview type forces every consumer to compile with preview too. Keep preview types internal, expose a stable wrapper or interface, and never let a preview type appear in a public signature you intend to ship.
Version bumps change meaning. Raising LangVersion from one numbered version to a later one is not inert: breaking changes can alter overload resolution or existing behavior. Read the breaking-changes notes for the version you're moving to before you bump, not after a red build.
Practice: make the call
You maintain an internal service library with one public entry point:
- An internal gate-state hierarchy (
Closed,Open) consumed by a switch inside the library. - A nested grid scan that exits with a Boolean flag plus
goto. - A public method
public GateState GetState(). - A pointer-based header reader used by one internal parser.
For each item, decide whether to adopt the relevant C# 15 feature now, later, or never, and justify it in one sentence. Then answer: which change would force your consumers to enable preview?
Check your answer
- Gate hierarchy β
closedis a good early candidate if the hierarchy stays internal: removed_arms and build errors on future omissions are cheap wins. If it were public, first decide whether the set is genuinely closed forever. - Nested scan β labeled
break/continueis pure ergonomics and the lowest-risk change here. Replace the flag andgoto, then verify the printed output is unchanged for the same inputs. GetState()β this is the leak. IfGateStatebecomes aunionorclosedtype, consumers must enable preview and pin to a supporting compiler. Prefer keeping the hierarchy internal behind a stable public type.- Pointer-based reader β the relaxations remove
unsafefrom declaration andfixed, but dereferences still require it. Shrinking the unsafe scope to the dereference is a safety win;unsafe(expression)helps where no block fits syntactically. Re-check after each compiler update.
Only the public GateState change forces consumers onto preview.
Adoption checklist
- Is
LangVersionset where you think β project property or file-based directive? - Did you verify the switch by compiling one preview-only construct and reading its diagnostic?
- Bucket check: modeling, ergonomics, or safety boundary?
- Does the feature reach a public signature that would drag consumers onto preview?
- Can it live behind a stable internal boundary?
- Did you read the breaking-changes notes for the version you're moving to?
- Will two SDK versions on two machines agree on what
previewmeans?
Modeling Closed Sets: Union Types and Closed Hierarchies
You have a value that can be exactly one of a few shapes β a pet that is a cat, a dog, or a bird; a gate that is open, closed, or locked. How do you tell the compiler that's the complete list, so it can prove your switch handles every possibility and you can delete the default arm? C# 15 introduces two mechanisms: union types and closed hierarchies. Both let you model a closed set, but they enforce it differently and break differently when the set grows. As covered in "C# 15 in the Evolution Line", these are preview features, so you need the preview language version and a compiler that supports them.
Union types: naming the cases
A union type declares that a value is one of a fixed list of case types β the allowed member types. You write it with the union keyword:
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
Pet is now a type whose values are exactly a Cat, Dog, or Bird. The compiler generates implicit conversions from each case type to the union, so you can assign a case directly without a cast:
Pet pet = new Dog("Rex"); // Dog converts implicitly to Pet
When you switch over a Pet, the compiler checks exhaustiveness β that every case type has a matching arm. You can omit default:
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
If you later add a fourth case type, the compiler will flag this switch as non-exhaustive. That warning is the feature working: it forces you to handle the new case everywhere.
β οΈ The union type feature is a preview proposal; its implementation is incomplete, so treat its capabilities as evolving. The examples here reflect the documented preview behavior. Verify against your compiler before relying on it.
Closed hierarchies: fixing the direct descendants
A closed hierarchy uses the closed modifier on a class to fix its set of direct descendants β the types that inherit directly from it β to the declaring assembly. The compiler knows every direct descendant at compile time:
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
GateState is implicitly abstract (you cannot instantiate it) and cannot be combined with sealed, static, or an explicit abstract. Because the direct descendants are all in this assembly, a switch over GateState can be exhaustive without a default arm:
string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
};
Derivation is not transitive: a descendant that is not itself marked closed can still be derived from in other assemblies. So if you want exhaustiveness to extend down a branch, mark the intermediate descendant closed as well. Otherwise, a switch over the base only guarantees coverage of direct descendants; deeper subclasses are still matched by the arm for their direct ancestor, so the switch remains exhaustive, but you cannot reason about them specifically.
Union type:
Pet
βββ Cat
βββ Dog
βββ Bird
Closed hierarchy:
GateState (closed)
βββ Closed
βββ Open
Exhaustiveness under change: a traced contrast
Both mechanisms make adding a case a breaking change for exhaustive switches, but the trace differs.
Start with the Pet union above (Cat, Dog, Bird) and a switch with three arms. Compiler: exhaustive, no warning.
Now change the declaration to public union Pet(Cat, Dog, Bird, Fish);. Recompile. The three-arm switch now produces a warning: not all cases covered. You must add a Fish arm. Every switch over Pet in every consuming assembly must be updated when recompiled.
Now the closed hierarchy. Start with GateState (Closed, Open) and a two-arm switch. Compiler: exhaustive, no warning.
In the same assembly, add public record class Locked : GateState;. Recompile. The two-arm switch now warns about unhandled Locked. So adding a direct descendant also invalidates exhaustiveness.
The key difference: the union declaration itself changes when you add a case β that's a change to the union's public type signature. The closed hierarchy adds a new type; the base class declaration is unchanged. That can matter for binary compatibility and API evolution.
β οΈ Pitfall: contextual keyword. closed is a contextual keyword β it only acts as a modifier in the right syntactic position. If you have a type named closed, writing closed MyClass might bind closed as a type rather than a modifier. Use @closed or rename to avoid ambiguity.
β οΈ Pitfall: cross-assembly exhaustiveness. Adding a direct descendant to a closed class in the declaring assembly invalidates exhaustiveness assumptions in other assemblies when they are recompiled against the new version. If they are not recompiled, a new descendant could reach a default arm or throw at runtime. Treat such additions as breaking changes for consumers.
Guided attempt: traffic light, two ways
Model a traffic light with states Red, Green, and Yellow. First as a closed hierarchy, then as a union. Write a Describe method that returns a string, using a switch expression with no default arm.
- Which version compiles warning-free?
- Now add a fourth state,
FlashingRed. In the closed hierarchy, add it as a direct descendant in the same assembly. In the union, add it as a fourth case type. Which switches break in each version? - If the fourth state were added in a different assembly, which version allows that?
Check your answer
Closed hierarchy version:
public closed record class TrafficLight;
public record class Red : TrafficLight;
public record class Green : TrafficLight;
public record class Yellow : TrafficLight;
string Describe(TrafficLight light) => light switch
{
Red => "Stop",
Green => "Go",
Yellow => "Caution",
};
Compiles warning-free.
Union version:
public record class Red;
public record class Green;
public record class Yellow;
public union TrafficLight(Red, Green, Yellow);
string Describe(TrafficLight light) => light switch
{
Red => "Stop",
Green => "Go",
Yellow => "Caution",
};
Compiles warning-free.
Adding FlashingRed:
- Closed hierarchy: add
public record class FlashingRed : TrafficLight;in the same assembly. The switch now warns about unhandledFlashingRed; add an arm. - Union: change the declaration to
public union TrafficLight(Red, Green, Yellow, FlashingRed);. The switch now warns; add an arm.
In both cases, every exhaustive switch must be updated.
Adding in a different assembly:
- Closed hierarchy: you cannot add
FlashingRedas a direct descendant in a different assembly; the compiler rejects it. You could derive fromRedifRedis not closed, but that's a different state. - Union: the union declaration must be changed where it is declared; you cannot add a case type from a different assembly without modifying the union there.
So both restrict where the set can grow, but in different ways.
Practice variation: adding a fourth case
Suppose you have a Document type with states Draft, Published, Archived. You now need to add Deleted. Predict every call site that will fail to compile (i.e., every exhaustive switch over Document). Then decide: does the closed-hierarchy form or the union form make that future addition cheaper? Consider that the closed hierarchy adds a new type without changing the base type's signature, while the union changes the union declaration itself. Write a short justification.
Extension Indexers: Indexing a Receiver That Has No Indexer
Square brackets make a promise. When you write list[3], you are telling the next reader that reaching the fourth element is cheap β the kind of access a type defines when it can answer an index without scanning. Extension indexers, new in C# 15, let you make that promise on a receiver type that never made it: you declare an indexer inside an extension block and callers index the receiver as though the indexer were a real member. The capability is real, and so is the trap. You can hand out bracket syntax that hides a linear scan behind a constant-time look.
The syntax, and why the receiver parameter must be named
An extension block is the extension(...) { ... } construct that groups extension members for a receiver type. Inside it, members behave as if they were declared on the receiver. C# 15 extends the set an extension block can host β previously methods and properties, now indexers too.
using System.Linq;
public static class SequenceIndexer
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
The parenthesized part names the receiver: here, sequence. The body uses that name to reach the value being indexed. Callers then write ordinary index syntax:
IEnumerable<int> numbers = Enumerable.Range(1, 10);
int third = numbers[2]; // 3
β οΈ Indexers are always instance members. Two rules fall straight out of that. First, an extension block that declares an indexer must supply a named receiver parameter β extension(IEnumerable<int>) with no name is a compile error, because the body would have no variable to index. Second, an extension indexer cannot be static; there is no static indexer to emulate. (Enabling any of this needs the preview language version; setting <LangVersion>preview</LangVersion> is covered in "C# 15 in the Evolution Line.")
Resolution: the instance indexer always wins
The compiler resolves numbers[2] in two passes. First it looks for an applicable instance indexer on the receiver's type. Only if none exists does it consider extension indexers in scope. So an extension indexer is a fallback, never an override β it cannot change the meaning of indexing on a type that already supports it.
| Receiver's compile-time type | Member selected |
|---|---|
| Has an applicable instance indexer | Instance indexer |
| No instance indexer, extension in scope | Extension indexer |
| Interface view with no indexer | Extension indexer |
The third row matters because resolution uses the compile-time type of the receiver, not the runtime type. A variable statically typed as an interface is judged by that interface's members.
Guided attempt: add one, then add the real thing
Start with a custom sequence type that has no indexer:
using System.Collections;
using System.Linq;
public sealed class Readings : IEnumerable<double>
{
private readonly double[] _values = [18.0, 21.5, 19.25];
public IEnumerator<double> GetEnumerator() =>
((IEnumerable<double>)_values).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _values.GetEnumerator();
}
public static class ReadingExtensions
{
extension(IEnumerable<double> readings)
{
public double this[int index] => readings.ElementAt(index);
}
}
Now index a Readings through both a concrete and an interface view:
Readings r = new();
double first = r[0]; // no instance indexer β extension indexer
IEnumerable<double> view = r;
double also = view[0]; // same extension indexer
Then add a genuine indexer to Readings:
public double this[int index] => _values[index]; // constant time
Predict before reading on: which indexer executes at each of the two call sites now, and does anything fail to compile?
Check your answer
r[0] now binds to the instance indexer β Readings has an applicable one, and the instance member always wins. view[0] still binds to the extension indexer, because IEnumerable<double> itself declares no indexer. Nothing fails to compile, and nothing warns you: adding an instance indexer silently rebinds every call site whose receiver is statically typed as Readings, changing both the code executed and its cost. That silent change is the practical hazard of declaring an extension indexer over a concrete type you do not control.
(This is a simplified picture β real overload resolution also weighs generic inference and implicit conversions, but for indexers the instance-first rule above is the decisive step.)
What the brackets hide
ElementAt is the villain here. On a plain IEnumerable<T> it advances the enumerator up to index times before yielding a value, so numbers[2] on our first example is O(n) β not the O(1) the bracket syntax implies. The cost compounds in a loop: indexing n positions in sequence re-walks the prefix each time, turning an ordinary traversal into O(nΒ²). ElementAt is optimized for IList<T> (it uses the indexer when the sequence really is a list), but our Readings type is not a list, so no such shortcut applies.
If you want bracket syntax, earn it by declaring the extension over a type that can actually answer an index cheaply:
extension(IReadOnlyList<int> values)
{
public int this[int index] => values[index]; // constant time
}
β οΈ When you cannot make that guarantee, restrict scope or document the cost at the declaration. A reader who sees seq[i] will not go hunting for the extension block to discover a linear walk.
Indexer or method? Placing the indexer in the extension-member set
Extension indexers complete a set that already contains extension methods and extension properties, and the choice among them is a readability contract, not a rule the compiler enforces:
- Indexer (
seq[i]) β promises the indexing is a natural, cheap access on the type's domain. Use it when the receiver genuinely represents an addressable sequence and the access is constant time. - Method (
GetAt(i)) β signals that work happens. A method name is a place to say walk, scan, or enumerate, which is exactly what an O(n) access deserves.
When the honest cost is unclear, prefer the method. GetAt(i) that costs O(n) is merely disappointing; seq[i] that costs O(n) is a lie the caller has no way to audit. And if a type is widely used and you only need indexed reads for one algorithm, a private helper called from that algorithm is usually better than a public extension indexer that every caller can reach.
Practice for this section: take the IEnumerable<double> extension indexer above and write a loop that reads view[0] through view[n-1]; state the total complexity, then explain how narrowing the receiver to IReadOnlyList<double> changes the answer. The lesson's closing transfer task in "Memory Safety: Moving the unsafe Boundary to the Operation" will ask you to justify an extension indexer alongside the other C# 15 features you adopt β carry the cost argument with you.
Labeled break and continue: Steering Nested Control Flow by Name
Inside two nested loops, a decision made in the inner loop often needs to end more than the inner loop. Say you scan a grid row by row and column by column, and the moment you hit the goal you want both loops to stop. Plain break won't do it:
for (int row = 0; row < rows; row++)
{
for (int column = 0; column < cols; column++)
{
if (grid[row, column] == 'G')
{
break; // leaves ONLY the inner loop β the outer loop keeps going
}
}
}
C# has always let break and continue act on exactly one statement: the innermost loop (or, for break, the innermost switch). Nested exits therefore needed a workaround β a Boolean flag (a bool variable set in the inner loop and re-checked at every outer level) or a goto to a label below the loops. C# 15 (preview, on the .NET 11 preview SDK) adds a third option: let the jump statement name the loop it wants to steer. Enabling the preview language version is covered in "C# 15 in the Evolution Line"; here we focus on what the syntax buys you once it's on.
Syntax: put the name on the loop, say the name in the jump
A label is an identifier followed by :. Labels are not new β goto has always used them. What is new is that break and continue can now name one. Place the label directly on the loop it identifies, then write break label; to exit that statement, or continue label; to start its next iteration:
outer: // the label names THIS loop
for (int row = 0; row < rows; row++)
{
for (int column = 0; column < cols; column++)
{
Console.Write($"({row},{column}) ");
if (grid[row, column] == 'B') continue outer; // abandon this row, start the next one
if (grid[row, column] == 'G') break outer; // leave the outer loop entirely
}
}
outer: for (...) β label sits on the loop it names
inner: for (...)
continue outer; β jump to outer's next iteration
break outer; β exit outer completely
continue outer; transfers control to the outer loop's next iteration β its increment (row++) and condition run, and the rest of the outer body, including any code after the inner loop, is skipped. break outer; leaves the outer loop and continues with whatever follows it. Because break may target a loop or a switch while continue may target only a loop, the two forms are not interchangeable. With no label, both keep their original innermost-statement meaning, so existing code is unaffected.
Three versions of one grid scan
The value of labeled jumps shows up when you compare them against the workarounds on an identical task. The flag version tracks whether the goal was found so the outer loop's condition can test it:
bool found = false;
for (int row = 0; row < rows && !found; row++)
{
for (int column = 0; column < cols; column++)
{
Console.Write($"({row},{column}) ");
if (grid[row, column] == 'G')
{
found = true;
break; // only leaves the inner loop
}
}
}
The goto version carries no extra variable but jumps to a label you must locate:
for (int row = 0; row < rows; row++)
{
for (int column = 0; column < cols; column++)
{
Console.Write($"({row},{column}) ");
if (grid[row, column] == 'G') goto GoalFound;
}
}
GoalFound:
Console.WriteLine();
| Version | Extra state | Exit points to audit |
|---|---|---|
bool found flag |
found |
outer condition && !found + inner break |
goto GoalFound; |
none | the jump + the far-away label |
break outer; |
none | the jump names its loop |
The flag version spreads the exit condition across two places β the outer condition and the assignment inside the inner loop β so a reader auditing "when does this stop?" must hold both in mind and trust that found is set nowhere else. The goto version has one exit point, but goto can jump almost anywhere, so the reader has to find the label to learn where control lands. The labeled version puts the intent in the jump statement itself: break outer tells you the target without a search. (This is a simplified comparison of ergonomics; the formal rules for which labels a goto may reach are a separate topic.)
Trace it: blocked cell, then goal cell
Run the labeled scan from the syntax section on a 4Γ4 grid, printing every visited cell. Scenario A β a blocked cell B in the second row (row 1, column 1):
row 0: . . . .
row 1: . B . . β blocked at (1,1)
row 2: . . . .
row 3: . . . .
Row 0 scans fully: (0,0) (0,1) (0,2) (0,3). Row 1 reaches (1,0), then (1,1) β it prints, matches B, and continue outer fires. Columns 2 and 3 of row 1 are never printed; we drop straight to row 2. Rows 2 and 3 each scan fully. Total: 14 cells, with (1,2) and (1,3) missing.
Scenario B β the goal G sits in row 2, column 2:
row 0: . . . .
row 1: . . . .
row 2: . . G . β goal at (2,2)
row 3: . . . .
Rows 0 and 1 scan fully (8 cells). Row 2 prints (2,0) (2,1) (2,2), matches G, and break outer ends both loops. Output stops at 11 cells; row 3 is never visited. With a plain break, only row 2's loop would end and row 3 would still scan β that difference is exactly the bug the flag workaround existed to fix.
Guided attempt: turn flag + goto into labeled jumps
Here is a nested search that pairs a flag with a goto β a common overlap, since the flag is often redundant once a goto is present:
bool found = false;
int hitRow = -1, hitCol = -1;
for (int row = 0; row < rows; row++)
{
for (int column = 0; column < cols; column++)
{
if (grid[row, column] == 'G')
{
found = true;
hitRow = row;
hitCol = column;
goto Done;
}
}
}
Done:
Console.WriteLine(found ? $"goal at {hitRow},{hitCol}" : "no goal");
Check your answer
Label the outer loop and replace the jump. The found flag is exactly equivalent to the sentinel hitRow >= 0, so it can go:
int hitRow = -1, hitCol = -1;
outer:
for (int row = 0; row < rows; row++)
{
for (int column = 0; column < cols; column++)
{
if (grid[row, column] == 'G')
{
hitRow = row;
hitCol = column;
break outer; // replaces `found = true; goto Done;`
}
}
}
Console.WriteLine(hitRow >= 0 ? $"goal at {hitRow},{hitCol}" : "no goal");
Why the output is unchanged for every input: in the original, found becomes true on exactly the iterations where hitRow/hitCol are assigned, and those assignments never happen again because the goto leaves both loops. So found == (hitRow >= 0) always. The new version removes one state variable and moves the code after the label into plain fall-through after the loop β no jump target to hunt for.
Pitfalls
β οΈ A label on a statement that is not a loop or a switch cannot be a jump target. x: Console.WriteLine(); followed by break x; is a compile error β the labeled jump must name an enclosing loop or switch.
β οΈ A labeled continue must name an enclosing loop. continue to a switch label is an error, because a switch has no "next iteration." Only break may target a switch.
β οΈ The label must be on an enclosing statement, never a sibling. You can steer outward, not sideways.
β οΈ continue outer skips the entire remainder of the outer body, not just the inner loop. If you placed a Console.WriteLine() after the inner loop to end a row, a continue outer skips it β a tidy reason to move such bookkeeping to the top of the loop body.
π οΈ The IDE0410 style rule recognizes the Boolean-flag and goto patterns this feature replaces and offers before-and-after rewrites. Running it on existing nested loops is a quick self-check: every hit is a candidate for a labeled jump.
Since C# 15 is a preview feature set, confirm the construct parses under your current LangVersion before adopting it team-wide β see "C# 15 in the Evolution Line" for how to pin or roll back the language version.
Memory Safety: Moving the unsafe Boundary to the Operation
A method declares a local pointer, pins an array with it, and never reads through it. Under the old accounting it must still be wrapped in unsafe, so anyone grepping the codebase for the keyword finds it and assumes this is where raw memory gets touched β and is wrong, because the risky operation lives elsewhere. C# 15's preview memory-safety work changes what the keyword tracks: not whether a pointer type exists, but whether an expression actually accesses memory outside the garbage collector's control.
The design goal: audit the access, not the type
Unmanaged memory is memory the garbage collector does not manage; you reach it through a pointer, a variable holding a machine address. The old rule tied unsafe to the existence of pointer types. The new rule ties it to the operations that read or write the memory behind them.
The central term: marking a member unsafe makes it requires-unsafe β the member may contain unmanaged-access operations, and the obligation to supply an unsafe context flows to whoever calls it. The compiler records which rule set an assembly was built with in System.Runtime.CompilerServices.MemorySafetyRulesAttribute, so a tool inspecting the binary later can tell which accounting was in force.
Type-based boundary (old) Operation-based boundary (new)
βββββββββββββββββββββββββ βββββββββββββββββββββββββββββ
"this member mentions a pointer" "this expression reads raw memory"
audit by: the `unsafe` keyword audit by: `*p`, `p->m`, `p[i]`,
function-pointer calls
The right-hand column is the whole set of places a memory-safety bug can begin, and each is now the thing the compiler demands a context for.
Pointer relaxations: what stops needing an unsafe context
Under LangVersion preview, five operations that used to need an unsafe context no longer do; four still do, and they are exactly the ones that touch memory.
| Operation | Unsafe context? |
|---|---|
Declaring int* p |
No |
Address-of &x |
No |
fixed (pinning) |
No |
stackalloc β pointer |
No |
sizeof(T), unmanaged T |
No |
Indirection *p |
Yes |
Member access p->m |
Yes |
Element access p[i] |
Yes |
| Function-pointer call | Yes |
Pinning is what fixed does: it tells the GC not to move an object while you hold its address. A function pointer is a variable holding the address of a function rather than a delegate. (Simplified map of the current preview; caller obligations are covered below.)
int number = 42;
int* pointer = &number; // no unsafe context needed under preview
int[] numbers = [10, 20, 30];
fixed (int* first = numbers) // pinning: no unsafe context needed
{
// int value = *first; // ERROR: dereference still requires unsafe
}
The commented line is the point: you may create and hold a pointer in ordinary safe code, and only reading through it needs an unsafe context.
unsafe(expression): one expression of unsafe context
Relaxing the pointer rules creates a practical gap: sometimes you need an unsafe context where a block cannot syntactically appear. Three such places are a field initializer (the expression giving a field its starting value), a constructor initializer (: base(...) or : this(...) in a constructor header), and a catch filter (the when (...) clause on a catch). None can host { ... } statements.
unsafe(expression) opens an unsafe context for a single expression:
class Header
{
// A field initializer can't contain an unsafe block,
// but it can contain an unsafe expression.
static readonly int Signature = unsafe(ReadSignature());
static unsafe int ReadSignature()
{
int rawValue = 0x1234;
int* pointer = &rawValue;
return *pointer; // dereference: needs unsafe context
}
}
Read the two halves together. ReadSignature dereferences, so it is marked unsafe and is requires-unsafe β calling it demands an unsafe context. The field initializer cannot provide a block, so unsafe(...) supplies the context inline. One restriction: the expression inside unsafe(...) may not contain statements, so a brace-bodied lambda does not belong there.
The safe keyword: closing the boundary from both sides
If unsafe now means "this member requires an unsafe caller," the model needs the opposite assertion. The safe contextual keyword β contextual meaning it is a keyword only where the grammar expects one β marks extern members (whose bodies live in another language or binary) and fields in explicit- or extended-layout types (types whose field offsets you control through layout attributes) as affirmatively safe, so a reviewer need not guess.
Enabling the full behavior takes three switches: the preview language version (setting <LangVersion>preview</LangVersion> and verifying it is covered in "C# 15 in the Evolution Line"), the updated-memory-safety-rules compiler feature for the caller obligations, and AllowUnsafeBlocks because pointers remain pointers.
Guided attempt: shrink the unsafe scope
The method below wraps its whole body in unsafe. Shrink the unsafe context to the minimum that still compiles, then state which callers, if any, inherit a requires-unsafe obligation.
static int FirstElement(int[] numbers)
{
unsafe
{
fixed (int* p = numbers)
{
return *p;
}
}
}
Take inventory: fixed pins the array, which no longer needs a context; *p reads through the pointer, which still does; nothing else touches memory.
Check your answer
static int FirstElement(int[] numbers)
{
fixed (int* p = numbers) // pinning needs no unsafe context
{
return unsafe(*p); // the single unmanaged read
}
}
The unsafe block disappears; only the dereference keeps a context. Because the method is no longer marked unsafe, it is not requires-unsafe, so callers of FirstElement need no context of their own β the payoff of an operation-level boundary: the risky line is visible and the obligation does not leak outward.
If you also listed stackalloc or sizeof as remaining unsafe operations, recheck the table: under preview, neither needs a context.
Pitfalls
Searching for unsafe no longer finds every access. With pointer declaration and pinning now safe operations, a codebase can contain live pointers and no unsafe keyword at all. Grep for the access operators β *, ->, [ on a pointer, and function-pointer invocation β not for the modifier. Do not overcorrect either: fixed inside a safe method still pins memory and still deserves review.
Preview rules move. The relaxations, safe, and the requires-unsafe obligations are gated behind the preview version and the updated-rules feature. Keep them behind a flag and re-read the breaking-changes notes after each compiler update instead of assuming the boundary you learned here still holds.
unsafe(...) is an expression, not a block. It cannot contain statements. If you need several, keep a small unsafe method and call it through unsafe(...), or restructure so a block is available.
Independent transfer: bring the features together
The program below has three parts: a nested grid search, a fixed-set state type, and a pointer-based header reader. Decide which C# 15 feature β if any β changes each part, rewrite it, and justify every construct you deliberately leave alone.
public abstract class GateState { }
public sealed class Closed : GateState { }
public sealed class Open : GateState { public float Percent; }
static string Describe(GateState state)
{
if (state is Closed) return "closed";
if (state is Open open) return $"{open.Percent}% open";
return "unknown";
}
static (int Row, int Col)? FindGoal(Cell[,] grid)
{
(int Row, int Col)? result = null;
for (int row = 0; row < grid.GetLength(0); row++)
{
for (int col = 0; col < grid.GetLength(1); col++)
{
if (grid[row, col].IsBlocked) break; // abandon this row
if (grid[row, col].IsGoal)
{
result = (row, col);
goto Done;
}
}
}
Done:
return result;
}
static int ReadSignature(byte[] header)
{
unsafe
{
fixed (byte* p = header)
{
return *(int*)p;
}
}
}
Check your answer
State type β closed hierarchy (from "Modeling Closed Sets"). Closing GateState fixes its direct descendants to this assembly, so Describe becomes an exhaustive switch expression needing no default. Because a closed class is implicitly abstract, the explicit abstract modifier goes away:
public closed class GateState { }
public sealed class Closed : GateState { }
public sealed class Open : GateState { public float Percent; }
static string Describe(GateState state) => state switch
{
Closed => "closed",
Open open => $"{open.Percent}% open",
};
The open pattern binds the instance so Percent is reachable; GateState itself still has no Percent member.
Grid search β labeled jumps (from "Labeled break and continue"). One labeled break names the statement the goto used to jump past:
static (int Row, int Col)? FindGoal(Cell[,] grid)
{
(int Row, int Col)? result = null;
outer: for (int row = 0; row < grid.GetLength(0); row++)
{
for (int col = 0; col < grid.GetLength(1); col++)
{
if (grid[row, col].IsBlocked) continue outer;
if (grid[row, col].IsGoal)
{
result = (row, col);
break outer;
}
}
}
return result;
}
Header reader β relaxed pointer rules. Only the dereference needs a context, so the block and the method's unsafe modifier both go:
static int ReadSignature(byte[] header)
{
fixed (byte* p = header) // pinning: safe
{
return unsafe(*(int*)p); // the only unmanaged read
}
}
Justifying what stays. sealed on Closed and Open remains right β it blocks further derivation. The nullable tuple return (int Row, int Col)? has nothing to do with C# 15. And ReadSignature is now a safe member, so its callers gain no obligation; keeping the unsafe modifier "just in case" would force a context on every call site and defeat the purpose. Audit your own version: does the Describe switch compile without default? Do the grid jumps name their targets instead of setting a flag? Is every remaining unmanaged read inside an unsafe(...) you can point at?
Outcome check
- I can state the goal:
unsafetracks memory-access operations, not pointer types. - I can list the five relaxed operations and the four that still need a context.
- I can explain requires-unsafe: an
unsafemember pushes the obligation to its callers. - I can use
unsafe(...)in a field initializer, constructor initializer, or catch filter, and say why a block would not fit. - I can review unmanaged access by operator β
*,->,p[i], function-pointer call β not by the keyword.