There aren't enough questions about creating immutable objects... so why not try it again with another approach. This time, it's a builder that maps properties to constructor parameters. Properties are selected via the With
method that also expects a new value for the parameter. Constructor parameters must match properties so it's primarily for DTOs that follow this pattern. Build
tries to find that constructor and creates the object.
// I need this primarily for the left-join of optional parameters.
internal class IgnoreCase : IEquatable<string>
{
public string Value { get; set; }
public bool Equals(string other) => StringComparer.OrdinalIgnoreCase.Equals(Value, other);
public override bool Equals(object obj) => obj is IgnoreCase ic && Equals(ic.Value);
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value);
public static explicit operator IgnoreCase(string value) => new IgnoreCase { Value = value };
}
public class ImmutableBuilder<T>
{
private readonly IList<(MemberExpression Selector, object Value)> _selectors = new List<(MemberExpression Selector, object Value)>();
public ImmutableBuilder<T> With<TProperty>(Expression<Func<T, TProperty>> selector, TProperty value)
{
_selectors.Add(((MemberExpression)selector.Body, value));
return this;
}
public T Build()
{
var ctors =
from ctor in typeof(T).GetConstructors()
let parameters = ctor.GetParameters()
// Join parameters and values by parameter order.
// The ctor requires them sorted but they might be initialized in any order.
let requiredParameterValues =
from parameter in parameters.Where(p => !p.IsOptional)
join selector in _selectors on (IgnoreCase)parameter.Name equals (IgnoreCase)selector.Selector.Member.Name
select selector.Value
// Get optional parameters if any.
let optionalParameterValues =
from parameter in parameters.Where(p => p.IsOptional)
join selector in _selectors on (IgnoreCase)parameter.Name equals (IgnoreCase)selector.Selector.Member.Name into s
from selector in s.DefaultIfEmpty()
select selector.Value
// Make sure all required parameters are specified.
where requiredParameterValues.Count() == parameters.Where(p => !p.IsOptional).Count()
select (ctor, requiredParameterValues, optionalParameterValues);
var theOne = ctors.Single();
return (T)theOne.ctor.Invoke(theOne.requiredParameterValues.Concat(theOne.optionalParameterValues).ToArray());
}
}
public static class Immutable<T>
{
public static ImmutableBuilder<T> Builder => new ImmutableBuilder<T>();
}
In case we already have an immutable type and want to modify it by changing just some values, the ImmutableHelper
can be used. It also provides the With
method expecting a new value. Then matches it and other properties with the constructor and uses origianl values as defaults and for the specified property the new value.
public static class ImmutableHelper
{
public static T With<T, TProperty>(this T obj, Expression<Func<T, TProperty>> selector, TProperty value)
{
var comparer = StringComparer.OrdinalIgnoreCase;
var propertyName = ((MemberExpression)selector.Body).Member.Name;
var properties = typeof(T).GetProperties();
var propertyNames =
properties
.Select(x => x.Name)
// A hash-set is convenient for name matching in the next step.
.ToImmutableHashSet(comparer);
// Find the constructor that matches property names.
var ctors =
from ctor in typeof(T).GetConstructors()
where propertyNames.IsProperSupersetOf(ctor.GetParameters().Select(x => x.Name))
select ctor;
var theOne = ctors.Single(); // There can be only one match.
var parameters =
from parameter in theOne.GetParameters()
join property in properties on (IgnoreCase)parameter.Name equals (IgnoreCase)property.Name
// They definitely match so no comparer is necessary.
// Use either the new value or the current one.
select property.Name == propertyName ? value : property.GetValue(obj);
return (T)theOne.Invoke(parameters.ToArray());
}
}
Example
This is how it can be used:
void Main()
{
var person =
Immutable<Person>
.Builder
.With(x => x.FirstName, "Jane")
.With(x => x.LastName, null)
//.With(x => x.NickName, "JD") // Optional
.Build();
person.With(x => x.LastName, "Doe").Dump();
}
public class Person
{
public Person(string firstName, string lastName, string nickName = null)
{
FirstName = firstName;
LastName = lastName;
NickName = nickName;
}
// This ctor should confuse the API.
public Person(string other) { }
public string FirstName { get; }
public string LastName { get; }
public string NickName { get; }
// This property should confuse the API too.
public string FullName => $"{LastName}, {FirstName}";
}
What do you say? Crazy? Insane? I like it? Let's improve it?
With
extensions method that helps to create modified copies of mutable types. However I didn't get the use case of theImmutableBuilder
.. Why not just calling the constructor? \$\endgroup\$