Contents
Introduction
C# 8 comes with a host of changes and language improvements that make it more interesting than ever to write code in the world’s most popular programming language. This article describes the most important new features and enhancements to the language.
Nullable Reference Types
Nullable value types have existed in C# since long. The same functionality has now been made available to reference types. What this essentially means is that you can now mark a reference variable as Nullable, which tells that compiler that it is okay if the variable is assigned a null value. For variables that are not marked as Nullable, the compiler treats them as Non-Nullable Reference Types.
A Nullable Reference Type is declared using the same syntax as a Nullable Value Type, i.e. suffixing it with a ?
operator. If the ?
is not appended to the type name, then it is treated as a non-nullable reference type. That includes all reference type variables in existing code when you have enabled this feature.
The compiler uses static analysis to determine if a nullable reference is known to be non-null. The compiler warns you if you dereference a nullable reference when it may be null. You can override this behavior by using the null-forgiving operator !
following a variable name. For example, if you know the myAddress
variable isn’t null but the compiler issues a warning, you can write the following code to override the compiler’s analysis:
class Address { string StreetName { get; set; } string City { get; set; } string State { get; set; } string ZipCode { get; set; } } Address? myAddress; //Nullable Reference Type var state = myAddress.State //Compiler may issue a warning for null reference var city = myAddress!.City //Compiler issues no warning for null reference
Asynchronous streams
C# 8.0 allows you to create and consume streams asynchronously. To enable your methods to return an asynchronous stream, your method must:
public static async IAsyncEnumerable<int> GetRandomSequence(int size, int max) { for (var i = 0; i < size; i++) { yield return _random.Next(max - (size - 1)); } await Task.Delay(50); }
- be decorated with the
async
modifier. - return an
IAsyncEnumerable<T>
. - contain
yield return
statements to return successive elements in the asynchronous stream.
To enumerate the return value from this method, use:
await foreach (var number in GetRandomSequence(1000, 1000000)) { Console.WriteLine(number); }
Index and Range
The new Index and Range types and the two new associated operators provide a neat and readable syntax while working with sequences.
- System.Index represents an index in a sequence.
- System.Range represents a sub range within a sequence.
- The index from end operator
^
, which specifies that an index is relative to the end of the sequence. - The range operator
..
, which specifies the start and end of a range as its operands.
Consider an array words
. The 0
index is the same as words[0]
. The ^0
index is the same as words[words.Length]
. Note that words
[^0]
throws an exception, just as words[words.Length]
does. For any number n
, the index ^n
is the same as words.Length - n
.
A Range
specifies the start and end of a range. The start of the range is inclusive, but the end of the range is exclusive, meaning the start is included in the range but the end is not included in the range. The range [0..^0]
represents the entire range, just as [0..words.Length]
represents the entire range.
Here are few examples:
string[] words = new string[] { // index from start index from end "She", // 0 ^8 "sells", // 1 ^7 "sea", // 2 ^6 "shells", // 3 ^5 "on", // 4 ^4 "the", // 5 ^3 "sea", // 6 ^2 "shore", // 7 ^1 }; // 8 (or words.Length) ^0
Console.WriteLine($"The last word is {words[^1]}"); // writes "shore" //The following code creates a subrange with the words "sells", "sea", and "shells".It includes words[1] through words[3]. The element words[4] is not in the range. var sellsSeaShells = words[1..4]; //The following code creates a subrange with "sea" and "shore". It includes words[^2] and words[^1]. The end index words[^0] is not included: var seaShore = words[^2..^0]; var allWords = words[..]; // contains "She" through "shore". var firstPhrase = words[..4]; // contains "She" through "shells" var lastPhrase = words[5..]; // contains "the", "sea" and "shore"
Ranges can also be declared as variables and used in expressions.
Range phrase = 1..4; var text = words[phrase]; //text contains "sells", "sea", and "shells"
Default Interface Members
Developers who write and distribute components must have gone through the problem with managing versioning of interfaces. In the COM/ActiveX world, it was referred to as “DLL Hell”. That is, once you publish an interface, you cannot add new members without breaking existing classes that implement the interface. Traditional method of handling such cases was to publish a new interface with the new members and ask your component users to start implementing your new interface in future releases of their code.
C# 8.0 allows you to provide a default implementation for your new interface members to ensure that existing classes that implement your interface are not broken. What this essentially means is that your interface methods and properties can now have a body and they can also have static members.
//Version 1 public interface IDevSpaceControl { bool WinFormsSupported { get; } }
//Version 2 public interface IDevSpaceControl { bool WinFormsSupported { get; } bool WinFormsSupportedOnDotNetCore { get { return false; } } }
In this example, the IDevSpaceControl
interface adds a new member WinFormsSupportedOnDotNetCore
without breaking existing clients by providing a default implementation.
To me personally, this is a design problem where the developer can get around by using abstract classes instead of interfaces, but Microsoft has purportedly done this to enable C# to interoperate with Android and Swift languages that already support similar features.
Switch Expressions
The switch statement has been enhanced to return an expression, this is called a switch expression. Here’s an example of a switch statement:
enum Importance { None, Trivial, Regular, Important, Critical } //switch statement internal int GetPriority(Importance importance) { switch (importance) { case Importance.None: return 0; case Importance.Trivial: return 25; case Importance.Regular: return 50; case Importance.Important: return 75; case Importance.Critical: return 100; default: return 50; }; }
Here’s equivalent switch expression. Note the brevity of the switch expression and how the code is very easy to read and understand.
//switch expression internal int GetPriority(Importance importance) => importance switch { Importance.None => 0, Importance.Trivial => 25, Importance.Regular => 50, Importance.Important => 75, Importance.Critical => 100, _ => 50 };
Switch Statement vs Switch Expressions
There are several syntax differences between a switch statement and a switch expression:
- The variable comes before the
switch
keyword, which makes it easy to distinguish the switch expression from the switch statement. - The
case:
is replaced with=>
- The
default
clause is replaced with a_
discard. - The bodies are expressions, not statements.
A switch expression must either return a value or throw an exception. If none of the cases match, the switch expression throws an exception. As with switch statements, the compiler generates a warning if all possible cases are not covered in the switch expression.
Property Patterns
Property pattern allows you to examine the property of an object and take action. Let’s take an example of Income Tax calculation where the tax rates change based on the State. The Address
class has a property called State
. Look how the State
property is elegantly examined and an appropriate expression returned.
public static decimal ComputeIncomeTax(Address location, decimal income) => location switch { { State: "TX" } => income * 0.25M, //Property pattern { State: "MA" } => income * 0.31M, { State: "NY" } => income * 0.35M, // other cases removed for brevity... _ => 0.30M };
Tuple Patterns
Tuple patterns enable you to switch by comparing multiple inputs to values expressed as a tuple. The following code shows an updated version of the switch expression code above:
enum CustomerRanking { Regular, Gold, Platinum, Titanium } internal int GetPriority(Importance importance, CustomerRanking customerRanking) => (importance, customerRanking) switch { (Importance.None, CustomerRanking.Regular) => 0, (Importance.None, CustomerRanking.Gold) => 5, (Importance.None, CustomerRanking.Platinum) => 10, (Importance.None, CustomerRanking.Titanium) => 15, (Importance.Trivial, CustomerRanking.Regular) => 20, (Importance.Trivial, CustomerRanking.Gold) => 25, (Importance.Trivial, CustomerRanking.Platinum) => 30, (Importance.Trivial, CustomerRanking.Titanium) => 35, (Importance.Regular, CustomerRanking.Regular) => 45, (Importance.Regular, CustomerRanking.Gold) => 50, (Importance.Regular, CustomerRanking.Platinum) => 55, (Importance.Regular, CustomerRanking.Titanium) => 60, (Importance.Important, CustomerRanking.Regular) => 65, (Importance.Important, CustomerRanking.Gold) => 70, (Importance.Important, CustomerRanking.Platinum) => 75, (Importance.Important, CustomerRanking.Titanium) => 80, (Importance.Critical, _) => 100, _ => 50 };
Note that you can ignore an argument in the switch expression by passing the _ discard. In the above example, different combinations of Importance
and CustomerRanking
are compared to arrive at a priority. However, when the Importance
is Critical
, priority is always 100 irrespective of the value of CustomerRanking
argument.
Compare it to the traditional way of writing multiple if
statements or nested switch
statements. Neat, isn’t it?
Readonly members in Structs
Any member of a struct can be declared as readonly by specifying the readonly
modifier. It is a design time intent to let the compiler know that the member does not undergo state change. It enables the compiler to perform optimizations when necessary to improve performance. It provides more granular control than declaring the entire struct as readonly.
public struct Circle { public double Radius { get; set; } public double Diameter => Radius * 2; public readonly double Circumference => Math.PI * Diameter; public readonly double Area => Math.PI * Math.Pow(Radius, 2); public readonly override string ToString() => $"Circle: Radius={Radius}, Diameter={Diameter}, Circumference={Circumference}, Area={Area}"; }
Note that Diameter
is not readonly
and Circumference
is declared as readonly
and references Diameter
. When a readonly
member references a non-readonly member, the compiler throws a warning:
Warning CS8656 Call to non-readonly member 'Circle.Diameter.get' from a 'readonly' member results in an implicit copy of 'this'
You can decorate the Diameter
property with readonly
to fix this issue.
Using Declarations
The using
declarations can now be scoped to a variable for better code readability. Tired of declaring multiple nested using declarations to efficiently dispose off objects? Try this new syntax:
static void WriteToFile(List<string> lines) { using var file = new System.IO.StreamWriter("sample.txt"); foreach (string line in lines) { if (!line.Contains("Second")) { file.WriteLine(line); } } //Dispose() called here }
Note that the scope of the using
variable is the scope where the variable is defined. On the contrary, in a using
statement, the scope would be the end of the using
statement itself.
static void WriteToFile(List<string> lines) { using file = new System.IO.StreamWriter("sample.txt") { foreach (string line in lines) { if (!line.Contains("Second")) { file.WriteLine(line); } } //Dispose() called here } }
If not used properly, this could have significant consequences on the working memory of your applications.
Conclusion
C# has been a very exciting language from the beginning and one of the very few languages that keeps getting new features and enhancements very often. C# 8.0 adds more arms to your already-rich arsenal. If you liked this article, feel free to leave a comment and share this article with your friends and colleagues who might be interested.
var city = myAddress!.City //Compiler issues no warning for null reference
should be
var city = myAddress?.City //Compiler issues no warning for null reference
Hi Tim, the ! is a new operator that tells the compiler not to enforce null check on the variable. The ? is used to define a Nullable type, they’re both different.