Double storage in class with primary constructor
.NET 8 is going to be released soon and with it C# 12 as well. Its largest new feature are most likely primary constructors. With this new feature, you can mostly get rid of trivial constructors used only for assigning parameter values to private fields. However, if you're not careful, you might end up with the same piece of data being stored in two fields which can easily diverge and cause bugs in your code.
You can reference primary constructor parameters in different parts of your class. Depending on where you do it, the parameter will be handled differently by the compiler:
- As soon as you reference a parameter in a body of any class member (method or property), the compiler will create a hidden field that will hold the value for you.
- If you only use a parameter in field or property initializers, the compiler won't create the before-mentioned hidden field, as the parameter is only directly needed during class initialization.
If you do both of the above, you're very likely to introduce a bug in your code, as the parameter value will end up being stored in two different fields. As soon as you modify the value of one of them, you will get a different value depending on where you read it from.
The following small class demonstrates this problem:
public class User(string username)
{
public string Username { get; set; } = username;
public override string ToString()
{
return username;
}
}
As you can see, the username
parameter is used in two places:
- as the initial value for the
Username
auto-property, and - as the return value of the
ToString()
method.
This means that the value will be stored in two places:
- in the auto-generated backing field for the
Username
property, and - in the auto-generated hidden field for the
username
parameter, used in theToString()
method.
You can check that for yourself in Sharplab. Just enter the above code and see the two username fields in the generated C# code:
[System.Runtime.CompilerServices.NullableContext(1)]
[System.Runtime.CompilerServices.Nullable(0)]
public class User
{
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string <username>P;
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string <Username>k__BackingField;
public string Username
{
[CompilerGenerated]
get
{
return <Username>k__BackingField;
}
[CompilerGenerated]
set
{
<Username>k__BackingField = value;
}
}
public User(string username)
{
<username>P = username;
<Username>k__BackingField = <username>P;
base..ctor();
}
public override string ToString()
{
return <username>P;
}
}
If you change the value of the Username
property after constructing the class, the values in the two fields will not be the same anymore, which most likely is not the desired behavior:
[Test]
public void PropertyAndParameterValueCanDiverge()
{
var user = new User("damir");
user.Username = "damira";
user.Username.Should().NotBe(user.ToString());
}
Fortunately, the compiler will emit a warning in such a case:
Parameter 'string username' is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event.
As long as you make sure to regularly fix the compiler warnings in your code, you should be safe.
So, what is the recommended fix for the above code? If you're using the parameter value to initialize another member in your class, you should then always use that member to read the parameter value instead of the parameter directly. In this particular case, this means that you should read the username value in the ToString()
method from the Username
property instead of from the username
parameter:
public class User(string username)
{
public string Username { get; set; } = username;
public override string ToString()
{
return Username;
}
}
Now, even if you change the value of the Username
property, the ToString()
method will correctly return its new value instead of the original value of the username
field:
[Test]
public void ParameterValueIsOnlyStoredInTheProperty()
{
var user = new User("damir");
user.Username = "damira";
user.Username.Should().Be(user.ToString());
}
If you want to run the code yourself, you can check my GitHub repository. The last commit contains the code with the suggested fix. The one before that, the broken code from the start of this post.
Primary constructors can be a useful addition to C# but you should understand how it works under the hood to avoid potential pitfalls. And even more importantly, you should never ignore compiler warnings, as more often than not they are an indicator of a potential bug in your code.