'Prevent C# "with expressions" bypassing constructor validation
I was disappointed to discover that C#'s "with expressions" allow the caller to bypass constructor validations on the record.
Consider:
record AscendingPair {
public AscendingPair(int small, int large)
{
if (small >= large) throw new ArgumentException("");
(Small, Large) = (small, large);
}
public int Small { get; init; }
public int Large { get; init; }
}
[Test]
public void Can_create_an_invalid_pair() {
var valid = new AscendingPair(1, 2);
var invalid = valid with { Small = 3 }; // This does not throw :(
}
Is there a smart workaround that would allow use of with
, but still enforce the validation?
Solution 1:[1]
The with
expression is lowered into something like this (check this on SharpLab):
var temp = valid.<Clone>$(); // you can't actually access this from C#
temp.Small = 3;
var invalid = temp;
This is documented here:
First, receiver's "clone" method (specified above) is invoked and its result is converted to the receiver's type. Then, each member_initializer is processed the same way as an assignment to a field or property access of the result of the conversion. Assignments are processed in lexical order.
Notes:
valid.<Clone$>()
just calls the copy constructor of the record (new AscendingPair(valid)
).- It is possible to set
temp.Small
here because you are doing it in the member initialiser list, which is one of the places where you can set init-only properties.
Now it should be clear how exactly the with
expression bypasses the check in your constructor.
One way to solve this is to move the check to the init
accessors:
record AscendingPair {
public AscendingPair(int small, int large)
{
if (small >= large) throw new ArgumentException("");
(Small, Large) = (small, large);
}
private int small;
private int large;
public int Small {
get => small;
init {
if (value >= large) {
throw new ArgumentException("");
}
small = value;
}
}
public int Large {
get => large;
init {
if (small >= value) {
throw new ArgumentException("");
}
large = value;
}
}
}
There is an important caveat to this fix though: the order of the assignments in the with
expression now matters. This is a natural consequence of how the with
expression is lowered, with each assignment being "processed in lexical order". For example:
var valid = new AscendingPair(1, 2);
var invalid = valid with { Large = 4, Small = 3 };
is fine, but,
var valid = new AscendingPair(1, 2);
var invalid = valid with { Small = 3, Large = 4 };
throws an exception.
We can't really do anything about this though, because to solve this problem, we would need to move the check to after all the assignments of the with
expression have completed, but as far as I know, we can't really know when that is inside the record. The lowered code does not call an extra method or anything like that at the end of the series of assignments.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | richardec |