Previously we looked at the performance of simple switch statements and expressions. Today we will consider switch expressions with when clauses.
This example shows a simple letter grade calculator as might be defined for a US high school:
public static class MyGrade { public static char Calc0(double pct) { if (pct < 0.6d) { return 'F'; } else if (pct < 0.7d) { return 'D'; } else if (pct < 0.8d) { return 'C'; } else if (pct < 0.9d) { return 'B'; } else { return 'A'; } } public static char Calc1(double pct) { return pct switch { _ when pct < 0.6d => 'F', _ when pct < 0.7d => 'D', _ when pct < 0.8d => 'C', _ when pct < 0.9d => 'B', _ => 'A', }; } }
The first method shows the legacy approach with successive if/else statements. Yawn. The second method replaces all this boilerplate with a simple switch expression. It’s much nicer to read, for sure. But will it result in any unexpected overhead? To the benchmark!
[SimpleJob(RuntimeMoniker.NetCoreApp31)] [MemoryDiagnoser] public class SwitchBenchmark2 { [Benchmark] public int IfElse() { int sum = 0; sum += MyGrade.Calc0(1.2d); sum += MyGrade.Calc0(0.94d); sum += MyGrade.Calc0(0.81d); sum += MyGrade.Calc0(0.7d); sum += MyGrade.Calc0(-0.1d); return sum; } [Benchmark] public int Switch() { int sum = 0; sum += MyGrade.Calc1(1.2d); sum += MyGrade.Calc1(0.94d); sum += MyGrade.Calc1(0.81d); sum += MyGrade.Calc1(0.7d); sum += MyGrade.Calc1(-0.1d); return sum; } }
The result:
| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated | |------- |---------:|---------:|---------:|------:|------:|------:|----------:| | IfElse | 19.84 ns | 0.123 ns | 0.109 ns | - | - | - | - | | Switch | 19.48 ns | 0.104 ns | 0.087 ns | - | - | - | - |
Luckily the performance is essentially the same. For fun, let’s peek at the IL anyway:
.method public hidebysig static char Calc0 ( float64 pct ) cil managed { // Method begins at RVA 0x2050 // Code size 63 (0x3f) .maxstack 8 // if (pct < 0.6) IL_0000: ldarg.0 IL_0001: ldc.r8 0.6 IL_000a: bge.un.s IL_000f // return 'F'; IL_000c: ldc.i4.s 70 // (no C# code) IL_000e: ret // if (pct < 0.7) IL_000f: ldarg.0 IL_0010: ldc.r8 0.7 IL_0019: bge.un.s IL_001e // return 'D'; IL_001b: ldc.i4.s 68 // (no C# code) IL_001d: ret // if (pct < 0.8) IL_001e: ldarg.0 IL_001f: ldc.r8 0.8 IL_0028: bge.un.s IL_002d // return 'C'; IL_002a: ldc.i4.s 67 // (no C# code) IL_002c: ret // if (pct < 0.9) IL_002d: ldarg.0 IL_002e: ldc.r8 0.9 IL_0037: bge.un.s IL_003c // return 'B'; IL_0039: ldc.i4.s 66 // (no C# code) IL_003b: ret // return 'A'; IL_003c: ldc.i4.s 65 // (no C# code) IL_003e: ret } // end of method MyGrade::Calc0 .method public hidebysig static char Calc1 ( float64 pct ) cil managed { // Method begins at RVA 0x2090 // Code size 73 (0x49) .maxstack 2 .locals init ( [0] char ) // if (pct < 0.6) IL_0000: ldarg.0 IL_0001: ldc.r8 0.6 IL_000a: bge.un.s IL_0011 // return 'F'; IL_000c: ldc.i4.s 70 IL_000e: stloc.0 // (no C# code) IL_000f: br.s IL_0047 // if (pct < 0.7) IL_0011: ldarg.0 IL_0012: ldc.r8 0.7 IL_001b: bge.un.s IL_0022 // return 'D'; IL_001d: ldc.i4.s 68 IL_001f: stloc.0 // (no C# code) IL_0020: br.s IL_0047 // if (pct < 0.8) IL_0022: ldarg.0 IL_0023: ldc.r8 0.8 IL_002c: bge.un.s IL_0033 // return 'C'; IL_002e: ldc.i4.s 67 IL_0030: stloc.0 // (no C# code) IL_0031: br.s IL_0047 // if (pct < 0.9) IL_0033: ldarg.0 IL_0034: ldc.r8 0.9 IL_003d: bge.un.s IL_0044 // return 'B'; IL_003f: ldc.i4.s 66 IL_0041: stloc.0 // (no C# code) IL_0042: br.s IL_0047 // return 'A'; IL_0044: ldc.i4.s 65 IL_0046: stloc.0 // (no C# code) IL_0047: ldloc.0 IL_0048: ret } // end of method MyGrade::Calc1
Funny enough, the switch expression code is again larger with an extra local, while the if/else formulation takes advantage of early returns. In the case of this microbenchmark, however, it doesn’t seem to make a difference in speed.
Summing up, switch expressions are great for conciseness and readability. Their runtime cost is not bad either, though they could perhaps benefit from better compiler optimization.