C#11 Expansion
C#11 Expansion
C# 11 EXPANSION
.NET 7 PROJECTS
Read after Level 2: Getting an IDE.
In the upcoming chapter, you’ll see instructions for creating a project from the template. The book will
mention “.NET 6 (or newer).” You’re in that “or newer” part. Choose .NET 7 (or newer!) to use all of the features
covered in this expansion.
You may also want to re-run the installer and ensure you have the latest version of everything.
Console.WriteLine(longText);
A raw string literal begins and ends with three quotation marks, which is how it differs from basic string
literals.
Pay attention to the whitespace within that text, and observe what is displayed:
Pick an option:
1. Up
2. Down
3. Left
4. Right
You cannot put text on the first line of the raw string literal. Once the triple quotation marks appear, you must
go to the following line immediately.
But notice that the leading whitespace on each line is skipped from the displayed string. This ensures you can
control how indented the text is (so the code can be visually clean and organized) without including a bunch
of extra whitespace in the string.
Technically, not all whitespace is ignored. The final line with the closing quotation marks governs the
expected leading whitespace. It must always be on its own line, with no text before it. Every line before it must
begin with identical whitespace, or it is a compiler error. The matching whitespace is eliminated, but any
additional whitespace remains.
string longText = """
Pick an option:
1. Up
2. Down
3. Left
4. Right
""";
Raw string literals are “raw” because they don’t allow escape sequences like \n for a new line, but there isn’t
a need, either. Rather than whitespace escape sequences like \n and \t, you can put a new line or tab directly
in the text.
You also don’t need to escape quotation marks that you want in the raw string literal. Since raw strings begin
and end with three quotation marks, a single (or two) quotation marks can appear naturally in the string:
string longText = """
"Thank you, Mario!" said Toad, "But our princess is in another castle!"
""";
String Interpolation
Raw string literals support string interpolation as you’d expect:
int x = 2; int y = 3;
string longText = $"""
X: {x}
Y: {y}
""";
But if you want to use string interpolation on a string that contains curly braces, you’ll need to add more $
symbols. For example, this code contains a snippet of the popular JSON data format, which naturally includes
some strings:
string jsonText = """
{
"x": 2,
"y": 3
}
""";
To keep those natural curly braces while also doing string interpolation, we’ll use two (or more) $ symbols.
Then single curly braces are considered plain text, and two curly braces indicate string interpolation:
int x = 2; int y = 3;
string jsonText = $$"""
{
"x": {{x}},
"y": {{y}}
}
""";
Add as many dollar signs as necessary if you need back-to-back curly braces to be plain text.
• Note: You must modify the text above to make it work. The above is illustrative, not the actual text to put into a raw
string literal.
• Run the program and enter good values for the units and type. Copy the output into a new program and run the
generated program, now with your chosen units and C# type.
• Note: Your program does not need to run the final string. C# can execute arbitrary text as code, but it is tricky (and
not secure). Take the text and copy/paste it into another program yourself.
Occasionally, these expressions are long enough to spread them over multiple lines. Before C# 11, that wasn’t
allowed. With C# 11, it is, and you can do stuff like this:
int x = 2, y = 3;
string text = $"They are {(x + 2) /
(y - 1) *
5} when combined.";
This change is a lovely refinement, removing an arbitrary limitation on what code could go inside a string
interpolation expression.
But as a guideline, if my interpolated expressions are long enough to span multiple lines, I split the expression
from the interpolated string anyway, like this:
int x = 2, y = 3;
int combined = (x + 2) / (y – 1) * 5;
string text = $"They are {combined} when combined.";
Handcrafted Conversions
The first option is to write code that converts specific enumeration values to strings or vice versa. In this
approach, we must write code to consider the various possibilities, resulting in handcrafted code that works
well for specific situations. Doing so is not hard and gives you complete control.
We start by deciding the mapping between enumeration values and strings.
Suppose we have this enumeration:
enum ElixirType { Invisibility, Strength, Regeneration, Coffee }
However, I will often put this code in a method, as this task is well suited to a method:
string ConvertToString(ElixirType elixir) => elixir switch
{
ElixirType.Invisibility => "shadow walking",
ElixirType.Strength => "strength",
ElixirType.Regeneration => "regeneration",
ElixirType.Coffee => "coffee"
};
I used an expression body for this ConvertToString method, but a block body with a return also works.
Converting the other direction is as simple as flipping the switch around. However, you may want several
strings to map to a single enumeration value. For example, "strength" and "Strength" could become
ElixirType.Strength. You can add additional arms to your switch. Still, if it is a capitalization thing, you
might also consider string’s ToLower() (or ToUpper()) method, and make the input lowercase, to
disregard casing differences entirely:
string ConvertToElixir(string elixirText) => elixirText.ToLower() switch
{
"shadow walking" => ElixirType.Invisibility,
"invisibility" => ElixirType.Invisibility,
"strength" => ElixirType.Strength,
"regeneration" => ElixirType.Regeneration,
"coffee" => ElixirType.Coffee
};
Alternatively, you could give the user a numbered menu and ask them to enter a number and use integers
instead of strings.
For these examples, you’ll note that the compiler gives you warnings for not covering all possible options.
We’ll deal with that in a moment.
Before moving on, let’s address the downside of this approach. Because this is hand-coded, it may require
maintenance over time. If you add or remove an enumeration value, you must revisit and update these
switches to account for the changes.
Generalized Conversions
An alternative to handcrafted conversion code is to use a general-purpose conversion tool.
For example, you can call the ToString() method on any enumeration value:
ElixirType elixir = ElixirType.Invisibility;
Console.WriteLine(elixir.ToString());
The ToString method creates a string that exactly matches the name of the enumeration value in the code.
So the code above will display "Invisibility".
That “matches the code” thing is crucial. With a generalized solution, you have no direct control over the text.
You lose the ability to display invisibility as “shadow walking.” You cannot include spaces or apostrophes. You
can’t even control capitalization. If you wanted "invisibility", you’re out of luck.
This problem leads some people to hack the name of the code element. For example, they might make all
enumeration values lowercase so that the produced text is "invisibility" and "strength". I strongly
discourage hacking the code to get the display you want for several reasons:
1. Enumeration values are UpperCamelCase by convention. Using a different scheme—especially when
done inconsistently—makes the code harder to understand.
2. You may use enumeration values in different contexts and need different capitalization or punctuation in
different places.
3. Not every string is a valid C# enumeration value identifier. If you want spaces or apostrophes, you’re out
of luck.
My advice is to either (a) live with the default capitalization and (lack of) punctuation that comes from this
generalized approach or (b) use a handcrafted version instead.
In truth, the default capitalization is good enough for many low-stakes scenarios, including the challenges in
this book. It’s okay if your capitalization does not align perfectly with what the book shows in samples or the
online solutions. But if you want things to match, don’t hack the enumeration value names; go with the
handcrafted version instead.
As a side note, I’ve seen people write code to make a generalized approach look nicer. For example, you might
convert to a string with ToString() and add a space before every capital letter before making it all
lowercase, making GoldenDragonsBreath become "golden dragons breath". Feel free to do this if
you want, but remember that it is tricky to cover all corner cases, so it is hard to perfect.
Many things automatically call ToString(), so you often don’t need to. Console.WriteLine is an
example. If you give Console.WriteLine the enumeration value you want to display, it will call
ToString() on it, meaning you can do this:
ElixirType elixir = ElixirType.Invisibility;
Console.WriteLine(elixir);
Converting from a string to an enumeration is a bit trickier. There are generalized tools for this, but they use
C# features we haven’t covered yet. I will show you anyway because I think it is intuitive enough. But we’ll
leave the explanations until later.
In the past, we’ve seen how you can use int.Parse and similar methods to parse an int value. There is a
similar method to parse an enumeration: Enum.Parse. The catch is that the Enum class doesn’t know which
specific enumeration type you want, so you need to state it. That is done using a feature called generics, which
we’ll spend a whole level discussing later. For now, I’ll show the pattern:
string elixirText = Console.ReadLine();
ElixirType elixir = Enum.Parse<ElixirType>(elixirText);
The enumeration type is placed in angle brackets (< and >). Doing so gives the method enough information
to know what type to return. Those angle brackets make Parse a generic method. Beyond showing an
example, I won’t bother explaining it further until later in the book. Swap out ElixirType for your desired
enumeration type.
Note that the text must exactly match the enumeration value as it appears in the code. If your enumeration
value is Invisibility, then "Invisibility" will work, but "invisibility" or "Invisible" won’t.
This code ensures we don’t crash by using some default value. We can go the other direction as well:
string ConvertToElixir(string elixirText) => elixirText.ToLower() switch
{
"shadow walking" => ElixirType.Invisibility,
"invisibility" => ElixirType.Invisibility,
"strength" => ElixirType.Strength,
"regeneration" => ElixirType.Regeneration,
"coffee" => ElixirType.Coffee,
_ => ElixirType.Coffee
};
What enumeration value should you pick in this situation? It depends. Sometimes, there’s an obvious default.
Coffee seems the least risky here, so it is a decent default. Sometimes, people will introduce an additional
enumeration value called Default, Unknown, or something similar. The downside is that Unknown
becomes a permanent, legitimate value, which is not always desirable.
Sometimes, you want to detect the invalid option and try again. Using Coffee doesn’t give you enough
information to know if the user intentionally chose Coffee or if it was a default. A specific Unknown or
Default value does. We can put the switch in a loop and try until the chosen option is not Unknown.
Later in this expansion, we’ll see how to use tuples (in the next level) to detect invalid inputs.
You don’t have as much control over handling bad inputs for generalized solutions. The ToString()
method will not crash because of bad input but may result in a number instead of useful text. There’s nothing
you can do about this.
The Enum.Parse method crashes if given invalid text, and you can’t do anything about that. This mirrors
how int.Parse or Convert.ToInt32 works, so it shouldn’t be surprising. In Part 3, we’ll learn about the
TryParse methods and how to use them to check for bad input.
The first four arms all use true in the first item of the tuple since the conversion worked. The last one is
false. The value is irrelevant in the last arm because the conversion was unsuccessful. ElixirType.
Invisibility is arbitrary.
While the switch produces a tuple, that tuple value is immediately unpacked into two distinct variables. That
wasn’t strictly necessary. The rest of the code could have accessed the items from the tuple. But this code felt
simpler to me.
This temporary association of a bool and an ElixirType is a good place to use tuples. That remains true
even after we learn about other tools in the upcoming levels.
SIMULA, VIN, AND USER INPUT
Read after Level 17: Tuples and before doing Simula’s Soup.
The next two challenges, Simula’s Soup and Vin Fletcher’s Arrows, are tricky. I’ve answered many questions
about these challenges in Discord. Without spoiling the fun, I want to provide extra content to put you on a
decent footing when working on these challenges.
The key: user input complicates these challenges significantly and is a secondary goal. Do it last.
For Simula’s Soup, start by making some tuples and displaying their contents. Perhaps start without naming
the parts of your tuples (use Item1, Item2, etc.), skip the enumerations (use strings), and skip the user input.
Get familiar with tuples first. Then replace the strings with enumerations. Then add the tuple element names.
Finally, add the user input.
If you do, you’ll end up with cleaner, simpler code and a better understanding of what tuples are for. (When
you’re all done, 75% of your code may be user input, but that’s stuff we’ve been doing for a while now. So focus
on tuples without user input first.)
For Vin Fletcher’s Arrows, do the same thing. Skip the user input initially. Make an Arrow class with some
fields, a constructor, and a method for computing the cost. Then make a few Arrow instances and display
their costs and what they’re made of (arrowhead type, fletching type, and length) so you can see how fields
work. Make a few Arrow instances directly in code without user input by calling the constructor with specific
values. Only after that works, add the user input.
I also recommend keeping the user input out of the Arrow class. Doing so will result in a more reusable
Arrow class, making it easier to understand the value of objects and classes without complicating it with user
input.
REQUIRED MEMBERS
Read after Level 20: Properties.
C# has two strategies for initializing an object: constructors and object-initializer syntax. The two can be used
independently or together.
In a constructor-focused paradigm, if a field or property must be initialized, you add a constructor parameter
for it:
public class Point
{
public float X { get; }
public float Y { get; }
public Point(float x, float y) { X = x; Y = y; }
}
Required properties must also have an init (or set) accessor to be settable in object-initializer syntax.
Fields can also be marked required:
public class Point
{
public required float X;
public required float Y;
}
Public fields kill the benefits of information hiding and abstraction, so I try to avoid them (especially if they
aren’t readonly). But they do have occasional uses.
Required properties provide a tool to guarantee the initialization of properties without requiring going the
constructor route.
AUTO-DEFAULT STRUCTS
Read after Level 28: Structs
Initializing structs got a little better in C# 11. Historically, any struct constructor has needed to initialize all
fields within the struct. Failure to initialize the field resulted in a compiler error. With C# 11, the compiler will
automatically add code to initialize any unassigned field to its default value. This mirrors classes.
public struct Point
{
public float X;
public float Y;
It is worth reiterating that you can sidestep constructors when using structs, so this feature can still be
bypassed.
Point p;
p.X = 3;
Console.WriteLine(p.Y); // COMPILER ERROR. `Y` is uninitialized.
The static keyword is natural since we’re making a static member, but the abstract keyword may come
as a surprise. Interface members are naturally abstract. Alas, due to conflicts with previous features, the
abstract keyword is necessary to ensure they work as you’d expect.
But this feature looks more useful than it is. Interfaces are a tool primarily for creating interchangeable
objects. When you use an interface type, you intentionally avoid committing to a specific type. To call a static
member, you must go through the type name—RandomLevelGenerator.Count, for example.
There are only a few places where you can leverage static interface members. One such place is a generic
method. For example, there is an IParseable<TSelf> interface that defines a static Parse method. Most
built-in types like int, float, and byte implement this interface. You could make a generic method with a
constraint limiting it to IParseables. With this constraint, you can call this method through the generic type
parameter:
public T[] ParseMany(string input) where T : IParsable<T>
{
string[] parts = input.Split(',');
T[] parsed = new T[parts.Length];
return parsed;
}
This method can call the generic interface method, Parse, without committing to a specific type because it
calls it through the generic type parameter T.
int[] a = ParseMany<int>("1,2,3,4");
float[] b = ParseMany<float>("1,2.5,-9.81");
byte[] c = ParseMany<byte>("0,255,127");
LIST PATTERNS
Read after Level 40: Pattern Matching.
C# 11 adds a few collection-centric pattern types known as list patterns. Suppose you monitor key presses and
put each key in a List<ConsoleKey>:
List<ConsoleKey> keys = new ();
while (true)
{
ConsoleKey key = Console.ReadKey(true).Key;
if (key == ConsoleKey.Enter) break;
keys.Add(key);
}
This code will loop and collect all key presses until you push the <Enter> key. We can now use pattern
matching to respond to the input. The simplest of these is the empty list pattern, which is empty brackets ([]):
string response = keys switch
{
[] => "Nothing",
_ => ""
};
Console.WriteLine(response);
The part between the brackets is a subpattern and can be anything. This happens to use the constant pattern.
We can match a whole set of items in the list by separating them with commas:
[ConsoleKey.W, ConsoleKey.A, ConsoleKey.S, ConsoleKey.D] => "WASD keys",
Order matters. This pattern will match W, A, S, then D, but not W, S, D, then A.
Lastly, you can use a .. in your list pattern to indicate “zero or more other items here.” For example:
[.., ConsoleKey.P] => "Ends with P",
[ConsoleKey.T, ..] => "Starts with a T",
[ConsoleKey.O, .., ConsoleKey.K, ConsoleKey.A, ConsoleKey.Y] => "Okay!",
The .. can come at the beginning, middle, or end. Unfortunately, you can only have a single .. in your list
pattern, so there isn’t a good way to express something like, “It must start with X, end with Y, and have a W
somewhere in the middle.”
While called list patterns, these patterns are not limited to only the List<T> type. If something has a Length
or Count and can be indexed with [], it will work, including arrays and other collection types.
GENERIC MATH
C# 11 added an interesting—albeit complex—bundle of features referred to as generic math. Generic math is
not necessarily something you must master right away, but understanding the basics is worthwhile.
When writing math-related code, you will occasionally get stuck debating which of several number types to
use. For example, you make a Point class and can’t decide whether to use floats or doubles. Or how about
a Min method that returns the smallest of two values? You may want the minimum of two ints, doubles,
longs, or ushorts.
Traditionally, there have been two solutions:
1. Pick one and stick with it. I’ve worked in many codebases with a Point class. Generally, we settled on a
single preferred option for the application or framework and lived with its problems to get its benefits.
2. Make multiple versions. If you cannot pick a single version, your alternative has been to make multiple
versions. For an example, look no further than the Math.Min method group, which has overloads for
each of the 11 built-in number types. Similarly, we could make PointF/PointD or Float
Point/DoublePoint classes or structs.
One of these solutions is usually good enough. However, they are not without limits. If you need multiple
versions, you’ll maintain multiple nearly identical piles of code. And these are sometimes large. For example,
MonoGame’s Vector2 struct spans 1200 lines of code. That repository chose to go with float and disregard
double—a popular choice in game development circles. If they needed to maintain a double-based
version, they’d have 2400 lines of nearly duplicate code to update, maintain, and debug. It is far from ideal.
C# has several tools for eliminating duplication in these situations. Let’s discuss why they don’t work here.
The first de-duplication tool is inheritance or interfaces. If there were a Number base class (or INumber
interface) that int, double, etc., derived from, then you could arguably make a Point class or a Min method
that used these instead of a specific type. There are a few problems with this:
1. You can’t force the types to match. If Number is a base class of int and double, and your Point class
uses Number, you may end up with an int for X and a double for Y.
2. The built-in number types are value types, so they can’t use inheritance. Even if we used an INumber
interface, storing value types in a reference-typed variable like INumber will box the value and place it
on the heap, resulting in unnecessary memory allocations.
3. Until C# 11, interfaces could not include static members. As we’ve seen, operators are static. That means
that even if we wanted to define an INumber interface, we wouldn’t have been able to include the basic
arithmetic operators on it that are commonly used with number types.
The second de-duplication tool is generics. Generics solve the first problem because you can make a
Point<T> class that requires both X and Y to match. It also solves the boxing conversion problem because
Point<int> and Point<double> use specific types, so they don’t need to be moved to the heap in a boxing
conversion. Alas, the third limitation applies to generics as well as inheritance.
But as we’ve seen, C# 11 supports static members to be included in an interface, including operator overloads.
With C# 11, this third limitation is gone, and a new set of possibilities open up.
Note that you’ll need to add a using directive to make this compile:
using System.Numerics;
INumber<T>, INumberBase<T>, and all other generic math interfaces are in the System.Numerics
namespace, which is not automatically included.
This Min method has a generic type constraint requiring that T implement INumber<T>. This constraint
ensures that the type—whatever it is—will have the < operator that this method uses. It can be used like this:
int smallest1 = Min<int>(2, 3);
double smallest2 = Min<double>(2.0, 3.0);
float smallest3 = Min<float>(2f, 3f);
Note that Min is a solved problem. The Math class provides overloads for all the different number types as an
alternative solution. You won’t need to write your own Min method. But this shows the mechanics on a small
problem, which can be repeated on other problems.
While INumber<T> and INumberBase<T> are the most useful generic math interfaces, there are many
more. There are generic interfaces for floating-point types, signed numbers, numbers with a known binary
(2-based) representation, and more. I won’t itemize the complete set here, but provide a link to explore more
if you need these more advanced interface types: https://learn.microsoft.com/en-us/dotnet/api/
system.numerics.
Operators in Interfaces
In C# 11, you can add static members to interfaces, which means you can add operators to an interface. You
won’t often need to do this yourself because of features we’ll see in a moment. But it is worth walking through
an example for illustration purposes. Here is a simple—but broken—take on an interface that requires an
addition operator:
public interface IAdditionOperators // DOES NOT COMPILE
{
static abstract int operator +(int a, int b);
}
While this looks good at a glance, there’s a problem: operators can only be placed in a type involved in the
operation. The addition defined above could only be added to the int type. We can fix that by using generics.
The version below actually compiles:
public interface IAdditionOperators<T> where T : IAdditionOperators<T>
{
static abstract T operator +(T a, T b);
}
The biggest surprise is the constraint, where T : IAdditionOperators<T>. That is noisy but necessary.
That’s the part that guarantees that the operator is only added to the T type, where it is allowed.
We could implement this interface like so:
public class Point : IAdditionOperators<Point>
{
public T X { get; }
public T Y { get; }
public Point(T x, T y) { X = x; Y = y; }
public static Point operator +(Point a, Point b) => new Point(a.X + b.X, a.Y + b.Y);
}
But this version of IAdditionOperators is missing a few things. It is common for an operator to take two
values of the same type and produce a result of that same type. Add two ints to get another int. Add two
floats to get another float. But those aren’t hard limits. You can add two values of different types; the
result does not need to match either. In other words, we don’t have a single generic type parameter; we have
three. So we’ll evolve our interface definition for maximum flexibility:
public interface IAdditionOperators<TSelf, TOther, TResult>
where TSelf : IAdditionOperators<TSelf, TOther, TResult>
{
static abstract TResult operator +(TSelf a, TOther b);
}
This definition allows us to make an addition operator with mixed types if necessary, with the downside of
needing to specify three generic type arguments that will usually be the same:
public class Point : IAdditionOperators<Point, Point, Point>
{
// ...
}
This illustrates how you’d include an operator in an interface definition. It’s complicated. The good news is,
you won’t need to do this often. In the System.Numerics namespace, right next to INumber<T> and
INumberBase<T>, there is a set of per-operator interfaces much like our IAdditionOperators interface.
Indeed, that example intentionally resembles the official IAdditionOperators definition.
Besides the official IAdditionOperators interface, there are other single-operator interfaces for each
existing operator. ISubtractionOperators for -, IMultiplicationOperators for *, IIncrement
Operators for ++, etc. A few operators are combined. IEqualityOperators is for == and !=, while
IComparisonOperators is for <, >, <=, and >=. Once again, I won’t dive into the details of every interface,
but you can review the set here: https://learn.microsoft.com/en-us/dotnet/api/system.numerics
While INumber<T> and INumberBase<T> are more common, if you only need a single operator, you might
consider one of these single-operator interfaces instead.
What happens here? It depends. By default, the bits wrap around, and number becomes int.MinValue.
What should have been a large positive number, mathematically, becomes a large negative number due to
the limits of the computer.
In Level 47, we learned how to create a checked context and get these integer types to throw an exception
instead of overflowing. It is almost as if there are two versions of the + operator—one for a checked context
and one for an unchecked context.
You can define a checked-specific operator overload for your types if you have a compelling need to mimic
this by defining a second operator overload and including the checked keyword in its definition:
public static Point operator checked +(Point a, Point b) { ... }
This is rarely needed. A single operator overload is usually good enough for checked and unchecked contexts.
But with C# 11, you have it as an option if needed.
THE FILE ACCESS MODIFIER
Read after Level 47: Other Language Features
C# 11 introduces a new accessibility level: file. A type or member marked with file is accessible only
within the file it is defined in.
file class SecretClass
{
// ...
}
OTHER FEATURES
Read after Level 47: Other Language Features
The following is a summary of the other C# 11 features. I’ve kept their descriptions short because they’re
minor enhancements and corner cases you won’t bump into often.
This code works even though the constants are strings, and span’s type is Span<char>. This feature
creates simple pattern-matching code without converting the Span<char> to a string.