Skip to content

Instantly share code, notes, and snippets.

@dsshep
Last active March 16, 2020 13:59
Show Gist options
  • Save dsshep/306edd9eeb0fe9665d35435bcf298f5c to your computer and use it in GitHub Desktop.
Save dsshep/306edd9eeb0fe9665d35435bcf298f5c to your computer and use it in GitHub Desktop.
Record like functionality in C#
// 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