Billion Dollar Mistake
Null Handling in C#
Rainer Stropek | @rstropek
The Problem
var p = Parse("FooBar");
if (string.IsNullOrEmpty(p.LastName))
{
Console.WriteLine("Person does not have a last name");
}
/// <summary>
/// Parses person data
/// </summary>
/// <returns>
/// Person data or <c>null</c> if given string is invalid.
/// </returns>
static Person Parse(ReadOnlySpan<char> personData)
{
var commaIx = personData.IndexOf(',');
if (commaIx == -1) { return null; }
return new Person(
personData[..commaIx].ToString(),
personData[(commaIx + 1)..].ToString());
}
record Person(string FirstName, string LastName);
Developer did not
read documentation 🥴
static Person? Parse(ReadOnlySpan<char> personData)
{
var commaIx = personData.IndexOf(',');
if (commaIx == -1) { return null; }
return new Person(
personData[..commaIx].ToString(),
personData[(commaIx + 1)..].ToString());
}
readonly record struct Person(string FirstName, string LastName);
Situation has always been a little bit better with value types (structs)
Goal: Make C# Null-Safe
- Other languages don't have null at all (e.g. safe Rust)
- Let's make C# equally powerful (or lets at least try to come near)
- How? Let's make reference types nullable, similar to value types
- Forces us to be explicit regarding nullability
Excursus: How Other Langs Solve it
fn main() {
let result = match get_maybe_null_string(2) {
Some(s) => s,
None => "Sorry, not string".to_string()
};
println!("{result}");
let result = get_maybe_null_string(2);
if let Some(s) = result {
println!("We got a result and it is {s}");
}
}
fn get_maybe_null_string(ix: i32) -> Option<String> {
if ix % 2 == 0 { Some("FooBar".to_string()) } else { None }
}
Rust
Code does not compile if None handling is forgotten
Challenges
- What about existing code?
- Errors would break existing code bases ➡️ Warnings
- Big bang migration not feasible ➡️ Stepwise enabling of null safeness
- If it is a compiler feature, what about class libraries?
- ➡️ Automatically add attributes
-
What about manual checks?
- ➡️ Intelligent flow analysis
- And in libraries? ➡️ Instrument code with attributes
-
What if I know better than the compiler?
- ➡️ Null forgiving operator !
Nullable Reference Types
Person? p = TryGetPerson(...)
Nullable Contexts
-
disable
- Work just like in the past
- No warnings, no protection, no nullable ref types
- Legacy projects that do not get a lot of love💔
-
warnings
- See what warnings you would get if you enable nullable ref types,
work on the warnings - No declaration of nullable ref types yet (would lead to warnings)
- See what warnings you would get if you enable nullable ref types,
Nullable Contexts
-
annotations
- Nullable reference types are possible
- No warnings in case of potential dereferencing of null
-
enable
- All shiny new features, including nullable ref types
- Warnings in case of potential dereferencing of null
- For your most 💕 projects
Nullable Contexts
How to Enable/Disable?
-
In .csproj
- For entire project
-
<Nullable>enable</Nullable>
-
Using #nullable in source code
-
E.g.#nullable enable
-
⚠️ Not a Perfect System! ⚠️
// Note: This code is warning free
#nullable enable
var p = new Person("Foo");
// ⚠️ Uninitialized struct member, initialized with default = null
if (p.LastName.Length > 0) // ⚡ null dereferencing
{
Console.WriteLine($"Person has a last name and it is {p.LastName}");
}
record struct Person(string FirstName)
{
public string LastName { get; set; }
}
⚠️ Not a Perfect System! ⚠️
// This code is warning free
#nullable enable
var people = new Person[10];
// ⚠️ Uninitialized array members, initialized with default = null
if (string.IsNullOrEmpty(people[0].LastName)) // ⚡ null dereferencing
{
Console.WriteLine($"Person has a last name and it is {people[0].LastName}");
}
record Person(string FirstName, string LastName);
⚠️ Not a Perfect System! ⚠️
// Set c to default. That will lead to a null reference.
c = default;
c.Value = 0815;
/// <summary>
/// Simple container using a ref field in a ref struct
/// </summary>
ref struct Container<T>
{
ref T value;
public Container(ref T value) => this.value = ref value;
public T Value
{
get => this.value;
set => this.value = value;
}
}
Flow Analysis
C# is smart 🧠
Intelligent Flow Analysis
#nullable enable
static string? GetHolidayName(DateOnly date)
{
return date switch
{
{ Day: 1, Month: 1 } => "New Year's Day",
{ Day: 4, Month: 7 } => "Independence Day",
{ Day: 25, Month: 12 } => "Christmas Day",
var d when d == GaussEaster(date.Year) => "Easter",
_ => null
};
}
static string TranslateHolidayName(string holidayName)
{
return holidayName switch {
"New Year's Day" => "Neujahr",
"Independence Day" => "Unabhängigkeitstag",
"Christmas Day" => "Weihnachten",
"Easter" => "Ostern",
_ => throw new ArgumentException($"Unknown holiday name: {holidayName}")
};
}
static DateOnly GaussEaster(int Y)
{
float A, B, C, P, Q, M, N, D, E;
A = Y % 19;
B = Y % 4;
C = Y % 7;
P = (float)(int)(Y / 100);
Q = (float)(int)((13 + 8 * P) / 25);
M = (int)(15 - Q + P - (int)(P / 4)) % 30;
N = (int)(4 + P - (int)(P / 4)) % 7;
D = (19 * A + M) % 30;
E = (2 * B + 4 * C + 6 * D + N) % 7;
var days = (int)(22 + D + E);
if ((D == 29) && (E == 6))
{
return new DateOnly(Y, 4, 19);
}
else if ((D == 28) && (E == 6))
{
return new DateOnly(Y, 4, 18);
}
else
{
if (days > 31)
{
return new DateOnly(Y, 4, days - 31);
}
else
{
return new DateOnly(Y, 3, days);
}
}
}
Intelligent Flow Analysis
#nullable enable
using System.Diagnostics;
var holidayName = GetHolidayName(new(2023, 4, 9));
// ⚠️ The following line gives us a warning as holidayName might be null
Console.WriteLine(TranslateHolidayName(holidayName));
if (holidayName != null)
{
// No warning as holidayName cannot be null (because of the if statement)
Console.WriteLine(TranslateHolidayName(holidayName));
}
Debug.Assert(holidayName != null, "holidayName cannot be null");
// No warning as holidayName cannot be null (because of Debug.Assert)
Console.WriteLine(TranslateHolidayName(holidayName));
if (!string.IsNullOrEmpty(holidayName))
{
// No warning as holidayName cannot be null
// (because of the IsNullOrEmpty inside the if statement)
Console.WriteLine(TranslateHolidayName(holidayName));
}
Sometimes, C# Needs Help
No flow analysis accross function calls
If that is the case, how can Debug.Assert and
string.IsNullOrEmpty do their magic 🪄?
Sometimes, C# Needs Help
#nullable enable
string? input = null;
const bool defaultValue = true;
var transformed = (input == null && defaultValue) ? "default" : input;
Console.WriteLine(transformed.Length);
var val = Transform(null, defaultValue);
Console.WriteLine(val.Length);
string? Transform(string? input, bool defaultValue)
{
if (input == null && defaultValue) { return "default"; }
return input;
}
Sometimes, C# Needs Help
namespace System.Diagnostics
{
public static partial class Debug
{
public static void Assert([DoesNotReturnIf(false)] bool condition) => ...
...
}
}
namespace System
{
public sealed partial class String : ...
{
public static bool IsNullOrEmpty([NotNullWhen(false)] string? value) { ... }
...
}
}
Sometimes, C# Needs Help
Null-related Operators
???!!!
Null Coalescing
#nullable enable
// Null coalescing operator
Console.WriteLine(IntoNotNull(null));
Console.WriteLine(MultipleIntoNotNull(null, "FooBar"));
static string IntoNotNull(string? maybeNullString) => maybeNullString ?? "default";
static string MultipleIntoNotNull(string? maybeNullString, string? second)
=> maybeNullString ?? second ?? "default";
// Null coalescing operator with throw expression
Console.WriteLine(ThrowIfNull("FooBar"));
static string ThrowIfNull(string? maybeNullString)
=> maybeNullString ?? throw new ArgumentNullException(null, nameof(maybeNullString));
ThrowIfNullStatement("FooBar");
static void ThrowIfNullStatement(string? maybeNullString)
=> _ = maybeNullString ?? throw new ArgumentNullException(null, nameof(maybeNullString));
Null Coalescing Assignment
#nullable enable
List<int>? numbers = null;
Push(42);
Push(43);
Console.WriteLine(JsonSerializer.Serialize(numbers));
void Push(int number)
{
// Null coalescing assignment operator
numbers ??= new List<int>();
numbers.Add(number);
}
Null Conditional Operator
#nullable enable
using System.Text.Json;
using System.Text.Json.Nodes;
Console.WriteLine(GetNameOfHero(42));
Console.WriteLine(GetNameOfHero(44));
string GetNameOfHero(int id)
{
var heroes = JsonNode.Parse("""
{
"theBoys": [
{ "id": 42, "name": "Starlight", "canFly": false },
{ "id": 43, "name": "Homelander", "canFly": true }
]
}
""");
var theBoys = heroes?["theBoys"] as JsonArray;
// +---- Null conditional indexer
// | +---- Null conditional operator
// | | +---- Null coalescing operator
// v v v
return theBoys?.FirstOrDefault(b => ((b?["id"])?.GetValue<int?>() ?? -1) == id)?["name"]
?.GetValue<string?>() ?? "not found";
}
Null Forgiving Operator
#nullable enable
using System.Text.Json;
using System.Text.Json.Nodes;
Console.WriteLine(GetNameOfHeroForgiving(42));
Console.WriteLine(GetNameOfHeroForgiving(44));
string GetNameOfHeroForgiving(int id)
{
var heroes = JsonNode.Parse("""
{
"theBoys": [
{ "id": 42, "name": "Starlight", "canFly": false },
{ "id": 43, "name": "Homelander", "canFly": true }
]
}
""");
var theBoys = heroes!["theBoys"] as JsonArray;
// Assumption: We KNOW that theBoys is not null and that properties id and name exist
// -> We can use the ! operator to tell the compiler that we know what we are doing
return theBoys!.FirstOrDefault(b => (b!["id"])!.GetValue<int>() == id)?["name"]
!.GetValue<string?>() ?? "not found";
}
Null Pattern
#nullable enable
Console.WriteLine(NullPattern1(null));
string NullPattern1(string? input)
{
// Note: Null pattern ignores overloads of == operator
if (input is null)
{
return "default";
}
return input;
}
Console.WriteLine(NullPattern2(null));
string NullPattern2(string? input)
{
if (input is not null)
{
return input;
}
return "default";
}
C# 11 🤘
Rainer Stropek | @rstropek
Billion Dollar Mistake - Null Handling in C#
By Rainer Stropek
Billion Dollar Mistake - Null Handling in C#
- 716