In C# it can be quite useful to obtain an instance of an object. What is important to us as designers of types, are the different ways we can permit others to do this.
Constructors
First we have the constructor. It's worth noting that whatever method you expose to your clients, somewhere, somehow a constructor is being called. A constructor allows us to communicate and enforce at compile time which information is required via the parameter list. Unfortunately the constructor gives us only a single method of enforcing constraints which is throwing an exception when they are violated.
public class Dimensions
{
public decimal Length { get; }
public decimal Width { get; }
public Dimensions(decimal length, decimal height)
{
if (Length <= 0) throw new ArgumentException("Must have a positive length.")
if (Height <= 0) throw new ArgumentException("Must have a positive height.")
Length = length;
Height = height;
}
}
This is great for aborting program execution immediately, but not a great way to handle errors. Note here as well that if a client sends us a non-positive length AND height, only the first exception will be hit. Making this method unsuitable for revealing all initialization errors.
Initializers
Object initializers were added as a form of syntactic sugar to the language and were very simple early on. Here is an example of the initial offering in C# 3
public class Dimensions
{
public decimal Length { get; set; }
public decimal Width { get; set; }
}
// Pre C# 3
Dimensions d = new Dimensions();
d.Length = 2m;
d.Width = 1m;
// Since C# 3
Dimensions d = new Dimensions()
{
Length = 2m;
Width = 1m;
}
Originally, object initializers weren't able to initialize read only properties. Your object's properties would need a public setter. This feature was later added in C# 9.0 with the init
keyword
// Since C# 9
public class Dimensions
{
public decimal Length { get; init; }
public decimal Width { get; init; }
}
Dimensions d = new Dimensions()
{
Length = 2m,
Width = 1m
}
d.Length = 3m; // compile error
If you use a manual backing field instead of an auto property, the init
keyword permits a code block allowing us to enforce constraints here. Unfortunately we are limited to throwing an exception, just as we were using a constructor.
public class Dimensions
{
private decimal _length;
public decimal Length
{
get => _length;
init
{
if (value <= 0) throw new ArgumentException("Must have positive length.")
_length = value;
}
}
public decimal Width { get; init; }
}
Dimensions d = new Dimensions()
{
Length = 0m // runtime exception
}
In the previous example. The initializer didn't include a value for the width property. This would compile just fine, and it wasn't until C# 11 that we finally had a way to require properties as part of object initializers with the required
keyword
// Since C# 11
public class Dimensions
{
public required decimal Length { get; init; }
public required decimal Width { get; init; }
Dimensions d = new Dimensions()
{
Length = 2m
} // compile error
}
Despite all these new language features, object initializers still retain the same issue as constructors and offer, near as I can tell, no tangible benefits. With the required
keyword, and init
block, object initializers and constructors seem to amount to an aesthetic difference. This is not the case with our last method of instantiation, which has been in the language from the very beginning.
Factories
Sorry to disappoint the Enterprise™ Developers, I do not mean the GoF Abstract Factory pattern. I am referring to a static function which has access to a private constructor for a type. Here is the trivial (useless) example:
public class Dimensions
{
public decimal Length { get; }
public decimal Width { get; }
private Dimensions(decimal length, decimal width)
{
Length = length;
Width = width;
}
public static Dimensions Create(decimal length, decimal width)
{
return new Dimensions(length, width);
}
}
In this case, we've no advantage over a simple constructor or initializer, but our static method can choose it's return type. This enables much more robust error handling for constraint violations. Beyond simply returning null, here is an idiomatic TryParse implementation which allows Roslyn's branch analysis to know when you've got an instance of Dimensions and not null.
public class Dimensions
{
public decimal Length { get; }
public decimal Width { get; }
private Dimensions(decimal length, decimal width)
{
Length = length;
Width = width;
}
public static bool TryParse(
decimal Length
decimal Width
[NotNullWhen(true)]
out Dimensions? dimensions)
{
dimensions = null;
if (Length <= 0 || Width <= 0) return false;
dimensions = new Dimensions(length, width);
return true;
}
}
Because these factories are just functions, they can implement whatever method of error handling you prefer. Here's an example with a passed in error handler.
public static Dimensions? Create(
decimal length,
decimal width,
Action<string> error)
{
bool hasErrors = false;
if (length <= 0)
{
error("Must have positive length.");
hasErrors = true;
}
if (width <= 0)
{
error("Must have positive width.");
hasErrors = true;
}
if (hasErrors) return null;
return new Dimensions(length, width);
}
Here's an example using a result type.
public static Result<Dimensions> Create(decimal length, decimal width)
{
List<string> errors = new();
if (length <= 0) errors.Add("Must have positive length.");
if (width <= 0) errors.Add("Must have positive width.");
return errors switch
{
[] => new Dimensions(length, width),
[message] => new Error(message),
_ => new AggregateError(errors)
};
}
Conclusion
As we can see the static factory functions provide us much more control around around error handling, and for this reason I tend to use static factory functions if I have any constraints at all.
View this post and more like it at my blog
Top comments (0)