FluentBuilder для immutable класса

В общем по следам этого вопроса решил написать примерчик, но к сожалению не осилил ?. Может кто из вас поможет дописать ?.

Имеем такой класс человека

class Person
{
    public Person(string firstName, string lastName,
        Address address, Employment employment)
    {
        FirstName = firstName;
        LastName = lastName;
        Address = address;
        Employment = employment;
    }

    public string FirstName { get; }
    public string LastName { get; }
    public Address Address { get; }
    public Employment Employment { get; }

    public override string ToString()
    {
        return $"{FirstName} {LastName}: "
            + Environment.NewLine +
            $"{Address}"
            + Environment.NewLine +
            $"{Employment}";
    }
}

с сопутствующими классами

class Address
{
    public string City { get; set; }
    public int PostCode { get; set; }

    public override string ToString()
    {
        return $"Адрес: {City}-{PostCode} ";
    }
}

class Employment
{
    public string CompanyName { get; set; }
    public string Position { get; set; }
    public int AnnualIncome { get; set; }

    public override string ToString()
    {
        return $"Работа: {CompanyName}, должность: {Position}, зарплата: {AnnualIncome}";
    }
}

Самостоятельно удалось написать только такой строитель

class PersonBuilder
{
    //аккумулятор названий свойств и их значений
    private readonly Dictionary<string, object> _propertiesToBuild;

    public PersonBuilder()
    {
        _propertiesToBuild = new Dictionary<string, object>();
    }

    //запоминание в словаре названия свойства и его значения
    public PersonBuilder Set<T>(Expression<Func<Person, T>> expression, T value)
    {
        var propertyName = ((MemberExpression)expression.Body).Member.Name;
        _propertiesToBuild.Add(propertyName, value);
        return this;
    }

    public T Include<T>(Expression<Func<Person, T>> expression) 
    {
        var propertyName = ((MemberExpression)expression.Body).Member.Name;
        if (propertyName == nameof(Person.Address))
        {
            var result = new Address();
            return (T)(object)result;
        }
        else
        {
            var result = new Employment();
            return (T)(object)result;
        }
    }

    //создание экземпляра на основе значений свойств из словаря
    public Person Build()
    {
        return new Person
        (
            firstName: GetPropertyValue<string>(nameof(Person.FirstName), "Неизвестно"),
            lastName: GetPropertyValue<string>(nameof(Person.LastName), "Неизвестно"),
            address: new Address(),
            employment: new Employment()
        );
    }

    private T GetPropertyValue<T>(string propertyName, T defaultValue)
    {
        return _propertiesToBuild.TryGetValue(propertyName, out var value) ? (T)value : defaultValue;
    }
}

И пока это работает так

var person = new PersonBuilder()
                           .Set(p => p.FirstName, "Андрей")
                           .Set(p => p.LastName, "Иванов")
                           .Build();

или так

Address address = new PersonBuilder()
                           .Set(p => p.FirstName, "Андрей")
                           .Set(p => p.LastName, "Иванов")
                           .Include(p => p.Address);

ну или так

Employment employment = new PersonBuilder()
                           .Set(p => p.FirstName, "Андрей")
                           .Set(p => p.LastName, "Иванов")
                           .Include(p => p.Employment);

А хотелось бы в результате как-то так

Person person = new PersonBuilder()
                           .Set(p => p.FirstName, "Андрей")
                           .Set(p => p.LastName, "Иванов")
                           .Include(p => p.Address)
                           .Set(a => a.City, "Москва")
                           .Include(p => p)
                           .Include(p => p.Employment)
                           .Set(e => e.Position, "инженер")
                           .Include(p => p)
                           .Build();

P.S. получившейся в итоге вариант здесь, спасибо @Vasek


Ответы (4 шт):

Автор решения: Vasek
class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public Address Address { get; set; }
    public Employment Employment { get; set; }
}

class Address
{
    public string City { get; set; }
    public int PostCode { get; set; }
}

class Employment
{
    public string CompanyName { get; set; }
    public string Position { get; set; }
    public int AnnualIncome { get; set; }
}

class Builder<T>
{
    public Builder Root { get; }
    public Dictionary<string, object> Props { get; }

    public Builder(Builder root)
    {
        Root = root;
        Props = new Dictionary<string, object>();
    }

    public Builder<T> Set<TProp>(Expression<Func<T, TProp>> expression, TProp value)
    {
        var propertyName = ((MemberExpression)expression.Body).Member.Name;
        Props.Add(propertyName, value);
        return this;
    }

    public virtual Builder<TProp> Include<TProp>(Expression<Func<Person, TProp>> expression)
    {
        return Root.Include(expression);
    }

    public TProp GetPropertyValue<TProp>(string propertyName, TProp defaultValue)
    {
        return Props.TryGetValue(propertyName, out var value) ? (TProp)value : defaultValue;
    }

    public virtual Person Build()
    {
        return Root.Build();
    }
}

class Builder : Builder<Person>
{
    private Builder<Address> _addressBuilder;
    private Builder<Employment> _employmentBuilder;

    public Builder()
        : base(null)
    {
        _addressBuilder = new Builder<Address>(this);
        _employmentBuilder = new Builder<Employment>(this);
    }

    public override Builder<TProp> Include<TProp>(Expression<Func<Person, TProp>> expression)
    {
        if (expression.Body is MemberExpression memberExpression) {
            var propName = memberExpression.Member.Name;
            return propName switch {
                nameof(Person.Address) => _addressBuilder as Builder<TProp>,
                nameof(Person.Employment) => _employmentBuilder as Builder<TProp>,
                _ => throw new Exception("lolo")
            };
        }

        if (expression.Body is ParameterExpression parameterExpression && parameterExpression.Type == typeof(Person)) {
            return this as Builder<TProp>;
        }

        throw new Exception("lolo");
    }

    public override Person Build()
    {
        return new Person() {
            FirstName = GetPropertyValue<string>(nameof(Person.FirstName), "!FirstName!"),
            LastName = GetPropertyValue<string>(nameof(Person.LastName), "!LastName!"),
            Address = new Address() {
                City = _addressBuilder.GetPropertyValue<string>(nameof(Address.City), "!City!"),
                PostCode = _addressBuilder.GetPropertyValue<int>(nameof(Address.PostCode), -1),
            },
            Employment = new Employment() {
                CompanyName = _employmentBuilder.GetPropertyValue<string>(nameof(Employment.CompanyName), "!CompanyName!"),
                Position = _employmentBuilder.GetPropertyValue<string>(nameof(Employment.Position), "!Position!"),
                AnnualIncome = _employmentBuilder.GetPropertyValue<int>(nameof(Employment.AnnualIncome), -1),
            }
        };
    }
}

static void Main(string[] args)
{
    var builder = new Builder()
        .Set(p => p.FirstName, "1")
        .Include(p => p.Address)
        .Set(a => a.City, "3")
        .Set(a => a.PostCode, 123)
        .Include(p => p.Employment)
        .Set(e => e.AnnualIncome, 5)
        .Set(e => e.Position, "6")
        .Include(p => p)
        .Set(p => p.LastName, "2")
        ;

    var person = builder.Build();   
}

Ps: Придумал такое. Исходил из того что надо как можно проще. Я в выраженях не очень силен поэтому может здесь что не совсем правильно, но идея думаю понятна будет

→ Ссылка
Автор решения: aepot

Как насчет такого решения?

UPD: Нашел баг. Если один и тот же тип будет встречаться в дереве несколько раз, оно не будет работать. Например если у Person будет Address WorkAddress и Address HomeAddress. Подумаю, как это можно разрулить.

Сюда стоит допилить защитного программирования, конструктор например надо лишний спрятать, и дергать его рефлексией. Может быть еще что не учел. Но вроде-как работает.

Немного изменил данные

class Person
{
    public string FirstName { get; }
    public string LastName { get; }
    public Address Address { get; }
    public Employment Employment { get; }

    public override string ToString()
    {
        return $"{FirstName} {LastName}: "
            + Environment.NewLine +
            $"{Address}"
            + Environment.NewLine +
            $"{Employment}";
    }
}

class Address
{
    public string City { get; }
    public int PostCode { get; }

    public override string ToString()
    {
        return $"Адрес: {City}-{PostCode} ";
    }
}

class Employment
{
    public string CompanyName { get; }
    public string Position { get; }
    public int AnnualIncome { get; }

    public override string ToString()
    {
        return $"Работа: {CompanyName}, должность: {Position}, зарплата: {AnnualIncome}";
    }
}

И вот что получилось

class FluentBuilder<T>
{
    private readonly Dictionary<Type, Dictionary<string, object>> _container;

    private Dictionary<string, object> _properties
    {
        get
        {
            if (!_container.TryGetValue(typeof(T), out var properties))
            {
                properties = new Dictionary<string, object>();
                _container[typeof(T)] = properties;
            }
            return properties;
        }
    }

    public FluentBuilder()
        => _container = new Dictionary<Type, Dictionary<string, object>>();

    public FluentBuilder(Dictionary<Type, Dictionary<string, object>> props)
        => _container = props;

    public FluentBuilder<Tprop> As<Tprop>() where Tprop : class
    {
        return new FluentBuilder<Tprop>(_container);
    }

    public FluentBuilder<T> Set<Tprop>(Expression<Func<T, Tprop>> expression, Tprop value)
    {
        var propertyName = ((MemberExpression)expression.Body).Member.Name;
        _properties.Add(propertyName, value);
        return this;
    }

    public FluentBuilder<Tprop> Include<Tprop>(Expression<Func<T, Tprop>> expression) where Tprop : class
    {
        var propertyName = ((MemberExpression)expression.Body).Member.Name;
        _properties.Add(propertyName, _properties);
        return As<Tprop>();
    }

    public T Build()
    {
        // для отладки, можно убрать
        foreach (Type t in _container.Keys)
            foreach (string s in _container[t].Keys)
                Console.WriteLine($"[{t.Name}] {s}");

        return (T)GetInstance(typeof(T));
    }

    private FieldInfo GetBackingField(Type type, string propertyName) 
        => type.GetField($"<{propertyName}>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance) 
        ?? throw new NullReferenceException($"Auto properties only allowed. Property: {type.Name}.{propertyName}");

    private object GetInstance(Type type)
    {
        object result = Activator.CreateInstance(type);
        Dictionary<string, object> props = _container[type];
        foreach (PropertyInfo p in type.GetProperties())
            if (props.TryGetValue(p.Name, out object v))
            {
                GetBackingField(result.GetType(), p.Name).SetValue(result, v == props ? GetInstance(p.PropertyType) : v);
            }
        return result;
    }
}

Использование

Person person = new FluentBuilder<Person>()
                .Set(p => p.FirstName, "Андрей")
                .Set(p => p.LastName, "Иванов")
                .Include(p => p.Address)
                .Set(a => a.City, "Москва")
                .As<Person>()
                .Include(p => p.Employment)
                .Set(e => e.Position, "инженер")
                .As<Person>()
                .Build();
Console.WriteLine(person);

Вывод в консоль

Андрей Иванов:
Адрес: Москва-0
Работа: , должность: инженер, зарплата: 0
→ Ссылка
Автор решения: MSDN.WhiteKnight

А хотелось бы в результате как-то так...

Я бы покритиковал сам постановку задачи.

  1. В чем смысл PersonBuilder? Это какая-то искусственная сущность, по сути мутабельный двойник иммутабельного класса, который нужно дублировать для каждого такого класса. Не лучше ли строить объекты унифицированным способом?

  2. .Set(p => p.FirstName, "Андрей") - использование лямбда-выражения ни с того, ни с сего, хотя анонимная функция тут вообще не вызывается. Почему не .Set("FirstName", "Андрей")?

  3. .Include(p => p) - вообще не понятно, что такое. Как включить самого себя? По логике, происходит переход к родительскому объекту, так что лучше бы создать отдельный метод "ToParent"

Я бы сделал это так. Допустим, мы принимаем соглашение, что у иммутабельного класса имена get-only свойств начинаются с большой буквы. Параметры конструктора соответствуют по типу его свойствам, а имя параметра начинается путем замены первой буквы имени свойства на такую же маленькую букву. Создадим такой класс:

public class ImmutableEntity
{
    static Dictionary<Type, Delegate[]> gettersCache = new Dictionary<Type, Delegate[]>();
    static Dictionary<Type, Delegate> constrCache = new Dictionary<Type, Delegate>();

    ImmutableEntity parent;//родительский объект, если этот объект - значение свойства другого объекта
    string parentProperty;//свойство родительского объекта
    
    Delegate[] getters;
    Delegate constr;

    protected ImmutableEntity()
    {
        if (gettersCache.ContainsKey(this.GetType()))
        {
            constr = constrCache[this.GetType()];
            getters = gettersCache[this.GetType()];
        }
        else
        {
            PropertyInfo p;
            //получаем первый конструктор, имеющий параметры
            ConstructorInfo ci = this.GetType().GetConstructors().
                Where(x => x.GetParameters().Length > 0).First();
            ParameterInfo[] p_arr;
            p_arr = ci.GetParameters();

            //получаем массив делегатов для получения значений свойств
            ParameterExpression[] expressions = new ParameterExpression[p_arr.Length];
            this.getters = new Delegate[p_arr.Length];

            for (int i = 0; i < p_arr.Length; i++)
            {
                //получаем имя свойства
                string propname = p_arr[i].Name;
                propname = Char.ToUpper(propname[0]).ToString() + propname.Substring(1);

                //создаем делегат
                p = this.GetType().GetProperty(
                 propname, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance
                );

                MethodInfo mb = p.GetGetMethod();
                Type delegateType = typeof(Func<,>).MakeGenericType(this.GetType(), p.PropertyType);
                getters[i] = mb.CreateDelegate(delegateType);

                //создаем выражение для параметра
                expressions[i] = Expression.Parameter(p.PropertyType);
            }

            //создаем делегат для вызова конструктора
            LambdaExpression expr = Expression.Lambda(Expression.New(ci, expressions), expressions);
            constr = expr.Compile();

            //сохраняем делегаты, чтобы не пересоздавать для каждого объекта одного типа
            gettersCache[this.GetType()] = getters;
            constrCache[this.GetType()] = constr;
        }
    }
    
    public ImmutableEntity Set(string prop, object val)
    {
        //получаем имя параметра из имени свойства
        string pname = prop;
        pname = Char.ToLower(pname[0]).ToString() + pname.Substring(1);

        //получаем первый конструктор, имеющий параметры
        ConstructorInfo ci = this.GetType().GetConstructors().
            Where(x => x.GetParameters().Length > 0).First();

        //формируем массив значений параметров
        ParameterInfo[] p_arr;
        p_arr = ci.GetParameters();
        object[] pars = new object[p_arr.Length];

        for (int i = 0; i < p_arr.Length; i++)
        {
            if (p_arr[i].Name == pname) 
            {
                //свойство, которое изменилось
                pars[i] = val; 
            }
            else
            {
                pars[i] = this.getters[i].DynamicInvoke(this);
            }
        }

        //создаем измененный объект
        ImmutableEntity ret=(ImmutableEntity)constr.DynamicInvoke(pars);
        ret.parent = this.parent;
        ret.parentProperty = this.parentProperty;
        return ret;
    }

    public static object GetDefault(Type t)
    {
        if (t.IsValueType) return Activator.CreateInstance(t);
        else return null;
    }

    public ImmutableEntity Include(string prop)
    {
        //получаем имя параметра из имени свойства
        string pname = prop;
        pname = Char.ToLower(pname[0]).ToString() + pname.Substring(1);

        //получаем первый конструктор, имеющий параметры
        ConstructorInfo ci = this.GetType().GetConstructors().
            Where(x => x.GetParameters().Length > 0).First();

        //формируем массив значений параметров
        ParameterInfo[] p_arr;
        p_arr = ci.GetParameters();
        object[] pars = new object[p_arr.Length];
        ImmutableEntity included =null;

        for (int i = 0; i < p_arr.Length; i++)
        {
            if (p_arr[i].Name == pname)
            {
                //свойство для включения
                included = this.getters[i].DynamicInvoke(this) as ImmutableEntity;

                if (included == null)
                {
                    included = New(p_arr[i].ParameterType);
                    included.parentProperty = prop;
                }

                pars[i] = included;
            }
            else
            {
                pars[i] = this.getters[i].DynamicInvoke(this);
            }
        }

        //включаем объект в родительский
        included.parent = (ImmutableEntity)constr.DynamicInvoke(pars);
        return included;
    }

    public ImmutableEntity ToParent()
    {
        if (this.parent == null) return null;

        return this.parent.Set(this.parentProperty, this);
    }

    public static T New<T>() where T:ImmutableEntity
    {
        return (T)New(typeof(T));
    }

    public static ImmutableEntity New(Type t)
    {
        //создаем пустой объект указанного типа
        ConstructorInfo ci = t.GetConstructors().
            Where(x => x.GetParameters().Length > 0).First();
        ParameterInfo[] p_arr = ci.GetParameters();
        object[] pars = new object[p_arr.Length];

        for (int i = 0; i < p_arr.Length; i++)
        {
            pars[i] = GetDefault(p_arr[i].ParameterType);
        }

        return (ImmutableEntity)ci.Invoke(pars);
    }
}

Его суть в использовании выражений для вызова конструктора объекта и получения значений свойств. Так можно строить любой объект, который удовлетворяет нашим соглашениям.

Сделаем иммутабельные классы производными от него:

public class Person: ImmutableEntity
{
    public Person(string firstName, string lastName,
    Address address, Employment employment)
    {
        FirstName = firstName;
        LastName = lastName;
        Address = address;
        Employment = employment;
    }

    public string FirstName { get; }
    public string LastName { get; }
    public Address Address { get; }
    public Employment Employment { get; }

    public override string ToString()
    {
        return $"{FirstName} {LastName}: "
            + Environment.NewLine +
            $"{Address}"
            + Environment.NewLine +
            $"{Employment}";
    }
}

public class Address : ImmutableEntity
{
    public Address(string city, int postCode)
    {
        this.City = city;
        this.PostCode = postCode;
    }

    public string City { get; }
    public int PostCode { get; }

    public override string ToString()
    {
        return $"Адрес: {City}-{PostCode} ";
    }
}

public class Employment : ImmutableEntity
{
    public Employment(string companyName, string position,int annualIncome)
    {
        this.CompanyName = companyName;
        this.Position = position;
        this.AnnualIncome = annualIncome;
    }

    public string CompanyName { get;  }
    public string Position { get;  }
    public int AnnualIncome { get;  }

    public override string ToString()
    {
        return $"Работа: {CompanyName}, должность: {Position}, зарплата: {AnnualIncome}";
    }
}

Тогда использовать можно так:

var person = (Person)ImmutableEntity.New<Person>().
    Set(nameof(Person.FirstName), "Ivan").
    Set(nameof(Person.LastName), "Ivanov").
    Include(nameof(Person.Address)).
    Set(nameof(Address.City), "Chelyabinsk").
    ToParent().
    Include(nameof(Person.Employment)).
    Set(nameof(Employment.Position), "Engineer").
    ToParent();
→ Ссылка
Автор решения: VladD

В качестве пропаганды всего нового и хорошего напомню, что immutable + builder из коробки поддерживается 9-ой версией C#.

Добавляем в .csproj <LangVersion>9.0</LangVersion>, и ваш пример можно магически записать следующим образом:

record Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
    public Address Address { get; init; }
    public Employment Employment { get; init; }
    public override string ToString() =>
        $"{FirstName} {LastName}: \n{Address}\n{Employment}";
}

record Address
{
    public string City { get; init; }
    public int PostCode { get; init; }
    public override string ToString() =>
        $"Адрес: {City}-{PostCode}";
}

record Employment
{
    public string CompanyName { get; init; }
    public string Position { get; init; }
    public int AnnualIncome { get; init; }
    public override string ToString() =>
        $"Работа: {CompanyName}, должность: {Position}, зарплата: {AnnualIncome}";
}

А код, использующий всё это, запишется так:

var person =
    new Person()
        with { FirstName = "Андрей" }
        with { LastName = "Иванов" }
        with { Address = new Address()
                             with { City = "Мюнхен" }
                             with { PostCode = 80000 } }
        with { Employment = new Employment()
                             with { CompanyName = "Google" }
                             with { Position = "CEO" }
                             with { AnnualIncome = 100500 } };

Всё!

→ Ссылка