Last active
March 16, 2020 13:59
-
-
Save dsshep/306edd9eeb0fe9665d35435bcf298f5c to your computer and use it in GitHub Desktop.
Record like functionality in C#
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// In F# if we have a record and instance: | |
type RecordOne = { | |
PropOne: string | |
PropTwo: int | |
} | |
let recordOne = { PropOne = "PropOne"; PropTwo = 1 } | |
// it can then be updated as so: | |
let updated = { recordOne with PropOne = "" } | |
// In C# this is a little more challenging, but can be solved with the code below. | |
// There are a few limitations: | |
// 1) Constructor arg names and property names must match (although case insensitive). | |
// 2) Constructor arg count and property count must be equal. | |
// 3) Every property must be instantiated from the constructor. | |
// 4) multiple updates require chained `With(...)` calls. | |
using System; | |
using System.Linq; | |
using System.Linq.Expressions; | |
public interface IRecord { } | |
public static class Record | |
{ | |
public static T With<T, TMember>(this T rec, Expression<Func<T, TMember>> valueName, TMember value) | |
where T : IRecord | |
{ | |
if (!(valueName.Body is MemberExpression memberExpression)) | |
throw new ArgumentException($"{nameof(valueName)} must be a property."); | |
var constructors = typeof(T).GetConstructors(); | |
if (constructors.Length != 1) | |
throw new InvalidOperationException($"{typeof(T).Name} must contain one constructor."); | |
var ctor = constructors[0]; | |
var properties = typeof(T).GetProperties(); | |
if (properties.Length != ctor.GetParameters().Length) | |
throw new InvalidOperationException("Number of properties does not match number of constructor arguments."); | |
var propertyCtorValues = new object[properties.Length]; | |
var ctorParams = ctor.GetParameters(); | |
for (int i = 0; i < ctorParams.Length; i++) | |
{ | |
var arg = ctorParams[i]; | |
var property = properties.SingleOrDefault(p => p.Name.ToLower() == arg.Name.ToLower()); | |
if (property == null) | |
throw new ArgumentNullException($"{typeof(T).Name} missing property for constructor arg '{arg.Name}'."); | |
if (property.Name == memberExpression.Member.Name) | |
{ | |
propertyCtorValues[i] = value; | |
} | |
else | |
{ | |
propertyCtorValues[i] = typeof(T).GetProperty(property.Name).GetValue(rec, null); | |
} | |
} | |
return (T)ctor.Invoke(propertyCtorValues); | |
} | |
} | |
public class RecordOne : IRecord | |
{ | |
public RecordOne(string propOne, int propTwo) | |
{ | |
PropOne = propOne; | |
PropTwo = propTwo; | |
} | |
public string PropOne { get; } | |
public int PropTwo { get; } | |
} | |
public class NotARecord | |
{ | |
public NotARecord(string propOne, int propTwo) | |
{ | |
PropOne = propOne; | |
PropTwo = propTwo; | |
} | |
public string PropOne { get; } | |
public int PropTwo { get; } | |
} | |
class Program | |
{ | |
static void Main(string[] _) | |
{ | |
var recordOne = new RecordOne("propOne", 1); | |
var updated = recordOne | |
.With(r => r.PropOne, "") | |
.With(r => r.PropTwo, 2); | |
var notARecord = new NotARecord("propOne", 1); // can't use `With(...)` here | |
} | |
} | |
// This is around 30x slower (net core 3.1) than regular property mutation. | |
// The version below is a little faster (~25x) but still fairly slow. | |
public static class Record | |
{ | |
private static readonly Dictionary<(Type, string), object> CachedGets = | |
new Dictionary<(Type, string), object>(); | |
private static readonly Dictionary<Type, Func<object[], object>> CachedConstructors = | |
new Dictionary<Type, Func<object[], object>>(); | |
public static T With<T, TMember>(this T rec, Expression<Func<T, TMember>> valueName, TMember value) | |
where T : IRecord | |
{ | |
if (!(valueName.Body is MemberExpression memberExpression)) | |
throw new ArgumentException($"{nameof(valueName)} must be a property."); | |
var constructors = typeof(T).GetConstructors(); | |
if (constructors.Length != 1) | |
throw new InvalidOperationException($"{typeof(T).Name} must contain one constructor."); | |
var ctor = constructors[0]; | |
var properties = typeof(T).GetProperties(); | |
if (properties.Length != ctor.GetParameters().Length) | |
throw new InvalidOperationException("Number of properties does not match number of constructor arguments."); | |
var ctorParams = ctor.GetParameters(); | |
var propertyCtorValues = new object[ctorParams.Length]; | |
for (int i = 0; i < ctorParams.Length; i++) | |
{ | |
var arg = ctorParams[i]; | |
var property = properties.SingleOrDefault(p => p.Name.ToLower() == arg.Name.ToLower()); | |
if (property == null) | |
throw new ArgumentNullException($"{typeof(T).Name} missing property for constructor arg '{arg.Name}'."); | |
if (property.Name == memberExpression.Member.Name) | |
{ | |
propertyCtorValues[i] = value; | |
} | |
else | |
{ | |
if (CachedGets.TryGetValue((typeof(T), property.Name), out var func)) | |
{ | |
propertyCtorValues[i] = (func as Func<T, object>)(rec); | |
} | |
else | |
{ | |
var instanceParam = Expression.Parameter(typeof(T)); | |
var expression = | |
Expression.Lambda<Func<T, object>>( | |
Expression.Convert( | |
Expression.Call(instanceParam, typeof(T).GetProperty(property.Name).GetGetMethod()), | |
typeof(object)), | |
instanceParam) | |
.Compile(); | |
CachedGets[(typeof(T), property.Name)] = expression; | |
propertyCtorValues[i] = expression(rec); | |
} | |
} | |
} | |
if (CachedConstructors.TryGetValue(typeof(T), out var c)) | |
{ | |
return (T)c(propertyCtorValues); | |
} | |
else | |
{ | |
var t = typeof(T); | |
var paramsInfo = ctor.GetParameters(); | |
var paramExp = Expression.Parameter(typeof(object[]), "args"); | |
var argsExp = new Expression[paramsInfo.Length]; | |
for (var i = 0; i < paramsInfo.Length; i++) | |
{ | |
var index = Expression.Constant(i); | |
var paramType = paramsInfo[i].ParameterType; | |
var paramAcessorExp = Expression.ArrayIndex(paramExp, index); | |
var paramCastExp = Expression.Convert(paramAcessorExp, paramType); | |
argsExp[i] = paramCastExp; | |
} | |
var newExp = Expression.New(ctor, argsExp); | |
var lambda = Expression.Lambda(typeof(Func<object[], object>), newExp, paramExp); | |
var compiled = lambda.Compile() as Func<object[], object>; | |
CachedConstructors[typeof(T)] = compiled; | |
return (T)compiled(propertyCtorValues); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment