A lot of C# 9-related content is around. Very often, records are mentioned as one of the most interestning new features. So, while we can find A LOT of buzz around them, I wanted to provide a distilled set of facts typically not presented when describing them.
Fact #1. You can use them in pre-.NET 5
Records has been announced as C# 9 feature (and thus .NET 5), and it is the officially supported way. But you can “not officialy” use most C# 9 features in earlier frameworks, as they don’t need the new runtime support. So, if being not “officially supported” does not bother you too much, just set proper LangVersion in csproj and you are (almost) done:
1 2 3 4 |
<PropertyGroup> <TargetFramework>netcoreapp3.1</TargetFramework> <LangVersion>9</LangVersion> </PropertyGroup> |
Trying to compile super typical example like the following:
1 2 3 4 5 |
public record Person { public string FirstName { get; init; } public string LastName { get; init; } } |
will still not compile, complaining about the lack of mysterious IsExternalInit type:
1 |
Error CS0518 Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported |
To be funny, the workaround is just to define it in your project (exactly as it is in the newer CoreLib, shipped with .NET 5):
1 2 3 4 |
namespace System.Runtime.CompilerServices { public class IsExternalInit{} } |
BTW, IsExternalInit is not required for the record usage by itself, but for init as discussed in https://github.com/dotnet/runtime/issues/34978 and https://github.com/dotnet/runtime/pull/37763. So if creating mutable records is ok, no need for that.
Sidenote: If you are interested what more you can “not officially” use, look at Using C# 9 outside .NET 5 #47701 discussion.
Fact #2. with-expression clones them
This fact has been mentioned here and there but it is worth repeating: with-expression is a shallow copy of an orignal instance with some properties overwritten. Thus, when writting:
1 2 |
var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; var otherPerson = person with { LastName = "Torgersen" }; |
It is translated into three steps: allocation, shallow copy and some properties overwritten:
1 |
person.<Clone>$().LastName = "Torgersen"; |
because <Clone>$() is just:
1 2 3 4 5 6 7 8 9 10 |
public virtual Person <Clone>$() { return new Person(this); } protected Person(Person original) { this.FirstName = original.<FirstName>k__BackingField; this.LastName = original.<LastName>k__BackingField; } |
That’s why it is much better (or at least, expected) that records are immutable. If not, shallow copies may be misleading when operating on more complex data structures.
Fact #3. Records can be generic and use constraints
It is much rarely mentioned that records can be generic:
1 2 3 4 5 6 |
public record Person<T> where T : class { public string FirstName { get; init; } public string LastName { get; init; } public T Data { get; init; } } |
And then the underlying class handles it perfectly. For example using the default .ToString behaviour for T Data field in case of ToString implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class Person<T> : IEquatable<Person<T>> { [CompilerGenerated] private readonly T <Data>k__BackingField; ... [System.Runtime.CompilerServices.NullableContext(1)] protected virtual bool PrintMembers(StringBuilder builder) { builder.Append("FirstName"); builder.Append(" = "); builder.Append((object)FirstName); builder.Append(", "); builder.Append("LastName"); builder.Append(" = "); builder.Append((object)LastName); builder.Append(", "); builder.Append("Data"); builder.Append(" = "); builder.Append(Data); return true; } |
and default equality behaviour for it:
1 |
EqualityComparer<T>.Default.Equals(<Data>k__BackingField, other.<Data>k__BackingField) |
Moreover, the same applies to positional records syntax:
1 |
public record Company<T>(string Name, T Data); |
Fact #4. Records can implement interfaces
As records are just classes underneath, they can derive from other classes and – which is showed a little less often – implement intefaces:
1 2 3 4 5 6 7 8 9 |
public interface IData<T> { T Data { get; } } public record PersonWithData<T> : Person, IData<T> { public T Data { get; init; } } |
and again, the same applies to positional records syntax:
1 |
public record Company<T>(string Name, T Data) : IData<T>; |
Fact #5. Records can be partial
Another interesting fact, records can be also partial:
1 |
public partial record Company(string Name); |
So, mostly, this gives us Source Generators possibility.
Fact #6. Records can use attributes
Another not mentioned, but sometimes desired property, may be possibility to add atributes, as in regular class:
1 2 3 4 5 6 7 8 |
public record Person { [JsonPropertyName("dataName")] public string FirstName { get; init; } [JsonPropertyName("dataCity")] public string LastName { get; init; } } |
Although, to make it working for positional records, you need to add some attribute-voodoo property::
1 2 |
public record Data([property:JsonPropertyName("dataName")] string Name, [property:JsonPropertyName("dataCity")]string City); |
Moreover, as Marc Gravell pointed on Twitter, with this voodoo, you can apply attributes both to the property itself and to the backing field:
1 |
public record Data([field:Foo("field")][property:Foo("prop")] string Name); |
And, obviously, you can apply your own attributes, to create really sophisticated code:
1 2 3 4 5 6 7 |
[AttributeUsage(AttributeTargets.All)] class FooAttribute : Attribute { public FooAttribute(string _) { } } public record Data([Foo("param")][field:Foo("field")][property:Foo("prop")] string Name); |
Last words…
Although, in summary nothing super surprising is happenning here – as we can use on records most of the syntax that is possible for classes – I found it interesting to confirm what is indeed possible or not. Anything more to add? Any ideas how we can utilize those possibilites? 🙂
No inheritance?
The title “You can use them in pre-C# 9” is a bit confusing, as you can’t use records in previous C# versions. What you meant to say is that you can use them in previous *.net runtime* versions (net core 3, & .net standard and I suspect any netfx version as well) using the approach you describe.
Right, misleading title fixed, thanks!
They can be inherited
That trick with C# 9.0 features on earlier runtimes – brilliant!