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.