10 Tiny Things in C#/.NET I Wish Were Different
In this blog post I look at a few things I wish were different in C# and .NET. I
consider this an anti-post in the sense that I actually believe there is an
unsound obsession in programming circles with counting code lines or characters
as exemplified in my own blog post World’s Smallest C# Program (featuring
N
).
It’s fun but has less relevance to writing good code than correctness, readability, debuggability, observability and performance. However, when possible one should choose the most succinct way to express code as long as the code is equivalent with regards to the mentioned points of merit. To be concrete this:
1
2
3
4
5
var letters = new [] { 'a', 'b' };
foreach (var letter in letters)
{
Console.WriteLine(letter);
}
is in my book better than:
1
2
var letters = new [] { 'a', 'b' };
letters.ToList().ForEach(Console.WriteLine);
It makes me sad when I see the above ToList()
given the allocations involved.
There is a reason LINQ doesn’t include a ForEach
extension
method. And both
snippets of code can be written in pretty much the same time in Visual Studio.
In any case for any developers out there please (unless code golfing 😉):
- Stop counting lines only 🤞
- Stop counting characters only 🤞
Yet here I am nagging about minor issues in C# and .NET (a developer platform I ♥) regarding things that could be more succinct. The difference is these are things at the foundation of the developer platform. Things we use every single day, and where I think there could have been better defaults that would not impact readability. It is, however, pretty futile giving these are also things that most likely won’t be changed or implemented. So please indulge me.
Below I show a before and after example as a gif demonstrating the things I wish were different (sorry for the lack of syntax highlighting in the after code). Just after I go through each of the 10 things one by one. At the end the example code is also listed as text both before and after.
UPDATE: To clarify based on responses to the post on
twitter this post is a
thought experiment. Not a list of proposals for changing C#/.NET. It’s a
“what if?” these things were different. From the inception of the platform for
example. Some of the mentioned things could potentially be implemented but
others should clearly not since they would break backwards compatibility or
similar like removing readonly
and making it default. It’s a tremendous value
add of the .NET/C# developer platform that you can take 20 year
old code and in
most cases it still compiles today on the most recent version of the platform
and compiler. Hence, it is wishful thinking and “pretty futile” 😅
using
should beuse
. Just like git commit messages should be in the imperative present tense like"Add X"
,"Remove Y"
,"Fix Z"
C# should be the same. I don’t know the reason for why C# selectedusing
vsuse
(besides C++ heritage) but both read fine when read out. “Using namespace System in this file” vs “Use namespace System in this file” but arguablyuse
is more succinct:1 2 3 4 5
use System; use System.IO; use System.Text; use static Console; use var reader = new StringReader("a;b;c;d");
- Use
this
instead of repeating type name for constructor/destructor etc. No need to constantly change these when copy pasting types or similar.1 2 3 4 5 6
class VeryLongTypeNameThatsAnnoyingToRepeat { public this() : this(42) { } public this(int value) { } public ~this() {} }
Dictionary<,>
should beMap<,>
. I don’t think there have been many days where I have been programming in C# where I didn’t useDictionary
so ifMap
is good enough a term for C++ I’d prefer this more succinct term:1
var letterToIndex = new Map<char, int>();
KeyValuePair<,>
should beKeyValue<,>
.Pair
is simply redundant. It’s a key and value.1
KeyValue<char, int> letterIndex = new('E', 4);
- Add
let
as a compliment tovar
but where the declared variable cannot be mutated/reassigned. This is not the same asconst
as the declared variable doesn’t have to be a constant.1 2 3
let text = "abc"; text = "def"; // ERROR: Cannot re-assign 'text' use let reader = new StringReader("a;b;c;d");
readonly
should not exist instead by default all declarations by default are readonly and mutable ones should be defined withmut
(ormutable
). Note thatvar
variables by default are mutable.1 2 3 4 5 6 7 8
struct RO // readonly struct by default { int _count = 42; // readonly by default } mut struct MU { mut int _index = 0; }
ReadOnly
should be abbreviatedRO
in type names e.g.IReadOnlyList<>
,ReadOnlySpan<>
should beIROList<>
,ROSpan<>
. At first this may feel quite un-C#-esque 😨, but there are plenty of abbreviations already likeTcp
andHttp
in .NET. However,ReadOnly
is quite a bit more pervasive in modern C#. The naming standard says 2 letter abbreviations should be all caps while 3 or more only have the first letter capitalized.1
IROList<char> letters = new char[] { 'a', 'b' };
- Recognize any
Invoke
method as “invokeable” similar to delegateInvoke
methods can be called by simply writingdelegate(...)
. For example:1 2 3
using System; Func<int, int> abs = Math.Abs; var u = abs(-42);
can be seen in sharplab.io to generate the following IL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
IL_0000: ldsfld class [System.Runtime]System.Func`2<int32, int32> Program/'<>O'::'<0>__Abs' IL_0005: dup IL_0006: brtrue.s IL_001b IL_0008: pop IL_0009: ldnull IL_000a: ldftn int32 [System.Runtime]System.Math::Abs(int32) IL_0010: newobj instance void class [System.Runtime]System.Func`2<int32, int32>::.ctor(object, native int) IL_0015: dup IL_0016: stsfld class [System.Runtime]System.Func`2<int32, int32> Program/'<>O'::'<0>__Abs' IL_001b: ldc.i4.s -42 IL_001d: callvirt instance !1 class [System.Runtime]System.Func`2<int32, int32>::Invoke(!0) IL_0022: pop IL_0023: ret
the
abs(-42)
is lowered to callingInvoke(-42)
on the delegate. Instead, C# should recognize any method calledInvoke
as invokeable, so you can write for example:1 2 3 4 5 6
var abs = new Abs(); var value = abs(-42); // Calls Invoke struct Abs { int Invoke(int value) => Math.Abs(value); }
I even proposed this as a new feature for C# more than 2 years ago in Proposal: Add invokeable?(…) as short hand for invokeable?.Invoke(…) and add support for any “invoke-able” type but it was closed as duplicate of Proposal: Functors and since
?()
as short-hand for null-coalescing operator being problematic for the C# parser. This feature seems feasible still 🤞 and it is a pattern we use a lot for value type functor based algoritms. An old example can be seen in RyuJIT: Poor code quality for tight generic loop with many inlineable calls (factor x8 slower than non-generic few calls loop). This is simular to how C# recognizes types with aGetEnumerator()
method without implementingIEnumerable<>
as in System.Private.CoreLib/src/System/Span.cs. private
should be implicit only (it’s almost always redundant and while you can remove it withdotnet format
why not just say it simply can’t be used - I’m disregardingprivate protected
or similar here):1 2 3 4 5 6 7
class C { int _member = 42; private int _nope = 17; // ERROR: 'private' is not valid int Double(int i) => i * 2; private int Triple(int i) => i * 3; // ERROR: 'private' is not valid }
fixed
should befix
. Same as 1. Use imperative present tense.1 2 3 4 5 6 7 8
var letters = new [] { 'a', 'b' }; fix (char* ptr = letters) { for (var i = 0; i < letters.Length; ++i) { ptr[i] += (char)2; } }
C# (original)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using static DemonstrativeLetterSplitter;
using var reader = new StringReader("a;b;c;d");
Split(reader, new ToUpper());
interface IFunc<T, TResult> { TResult Invoke(T arg1); }
readonly record struct ToUpper : IFunc<char, char>
{
public char Invoke(char c) => char.ToUpper(c);
}
static class DemonstrativeLetterSplitter
{
static readonly Action<string> Log;
static DemonstrativeLetterSplitter() => Log = Console.WriteLine;
static int _count = 0;
public static void Split<TFunc>(TextReader reader, TFunc change)
where TFunc : IFunc<char, char>
{
var text = reader.ReadToEnd();
var letters = text.Split(';').Select(n => n[0]).ToArray();
Do(letters, change);
var letterToIndex = MakeLetterToIndex(letters);
foreach (var pair in letterToIndex)
{
Log($"{_count++:D3}: {pair.Key} = {pair.Value}");
}
}
private static unsafe void Do<TFunc>(Span<char> letters, TFunc change)
where TFunc : IFunc<char, char>
{
fixed (char* letterPtr = letters)
{
for (var i = 0; i < letters.Length; i++)
{
ref var letter = ref letterPtr[i];
letter = change.Invoke(letter);
}
}
}
private static IReadOnlyDictionary<char, int> MakeLetterToIndex(
ReadOnlySpan<char> letters)
{
var letterToIndex = new Dictionary<char, int>(letters.Length);
for (var i = 0; i < letters.Length; i++)
{
letterToIndex.Add(letters[i], i);
}
return letterToIndex;
}
}
C# (nietras)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
use System;
use System.Collections.Generic;
use System.IO;
use System.Linq;
use static DemonstrativeLetterSplitter;
use let reader = new StringReader("a;b;c;d");
Split(reader, new ToUpper());
interface IFunc<T, TResult> { TResult Invoke(T arg1); }
record struct ToUpper : IFunc<char, char>
{
public char Invoke(char c) => char.ToUpper(c);
}
static class DemonstrativeLetterSplitter
{
static Action<string> Log;
static this() => Log = Console.WriteLine;
static mut int _count = 0;
public static void Split<TFunc>(TextReader reader, TFunc change)
where TFunc : IFunc<char, char>
{
let text = reader.ReadToEnd();
let letters = text.Split(';').Select(n => n[0]).ToArray();
Do(letters, change);
let letterToIndex = MakeLetterToIndex(letters);
foreach (let pair in letterToIndex)
{
Log($"{_count++:D3}: {pair.Key} = {pair.Value}");
}
}
static unsafe void Do<TFunc>(Span<char> letters, TFunc change)
where TFunc : IFunc<char, char>
{
fix (char* letterPtr = letters)
{
for (var i = 0; i < letters.Length; i++)
{
ref var letter = ref letterPtr[i];
letter = change(letter);
}
}
}
static IROMap<char, int> MakeLetterToIndex(
ROSpan<char> letters)
{
let letterToIndex = new Map<char, int>(letters.Length);
for (var i = 0; i < letters.Length; i++)
{
letterToIndex.Add(letters[i], i);
}
return letterToIndex;
}
}
Output
1
2
3
4
000: A = 0
001: B = 1
002: C = 2
003: D = 3
PS: The example program is solely intended to exemplify the C#/.NET changes as suggested here not good code.