6
\$\begingroup\$

I've made an object builder which can create all sorts of classes, it can also create immutable objects.

There are 2 requirements that must be met in order for this pattern to work with fully immutable objects.

  1. The properties that you want to be modified must have a backing readonly field.
  2. The backing field must follow some convention in my case it must start with small letter and optionally it can start with _ too, of course this can be extended to match your preferences.

Here is the actual builder class :

public class ImmutableObjectBuilder<T> 
    where T : class
{
    private class PropertyWrapper
    {
        public PropertyInfo Property { get; }
        public object Value { get; set; }

        internal PropertyWrapper(PropertyInfo property, object value)
        {
            Property = property;
            Value = value;
        }
    }

    private readonly IDictionary<string, PropertyWrapper> propertiesInfo = new Dictionary<string, PropertyWrapper>();

    public ImmutableObjectBuilder()
    {
        PropertyInfo[] properties = typeof(T).GetProperties();
        foreach (PropertyInfo p in properties)
        {
            propertiesInfo.Add(p.Name, new PropertyWrapper(p, null));
        }
    }

    public ImmutableObjectBuilder<T> WithValue<TValue>(string propertyName, TValue value)
    {
        if (!propertiesInfo.ContainsKey(propertyName))
        {
            throw new KeyNotFoundException("The type of TValue is different than the type of T");
        }
        propertiesInfo[propertyName].Value = value;
        return this;
    }

    public TObject Build<TObject>(Func<TObject> instance)
        where TObject : T
    {
        TObject localInstance = instance();
        PropertyInfo[] instanceProperties = localInstance.GetType().GetProperties();

        PropertyInfo[] matchingProperties =
        (from tProperty in instanceProperties
            from localProperty in propertiesInfo
            where tProperty.Name == localProperty.Key &&
                  tProperty.PropertyType == localProperty.Value.Property.PropertyType
            select localProperty.Value.Property).ToArray();

        foreach (PropertyInfo p in matchingProperties)
        {
            if (p.CanWrite)
            {
                p.SetValue(localInstance, propertiesInfo[p.Name].Value);
            }
            else
            {
                p.SetReadonlyValue(localInstance, propertiesInfo[p.Name].Value);
            }
        }
        return localInstance;
    }

    public TObject Build<TObject>()
        where TObject : T, new() => Build(() => (TObject) Activator.CreateInstance(typeof(TObject)));
}

It works primarily with reflection in order to obtain all the properties from the generic type that is given to the class upon creation. The only function that sets the values of the class here is WithValue<TValue>(string propertyName, TValue value).

I was originally planning to create method dynamically using System.Reflection.Emit.DynamicMethod class which would have respective names and wouldn't requrie the string name parameter but I'm not fluent in IL and the are quie some downsides in this approach such as performance loss, I decided to work with what I can and I made that single method.

The other important function here is of course TObject Build<TObject>(Func<TObject> instance) where TObject : T. It matches all the properties from the type T and the type TValue in order to assign values properly.

Next we check if the property is makred as readonly if that's the case it's using an extension method that I will show you in a moment, but if not just use the default .SetValue(obj, value) method.

I have a single extension SetReadonlyValue

public static class Extensions
{
    public static void SetReadonlyValue(this PropertyInfo property, object obj, object value)
    {
        FieldInfo[] allFields = property.DeclaringType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
        FieldInfo propertyBackingField =
            allFields.FirstOrDefault(f =>
                    f.Name == ConventionConverter.PublicToPrivate(property.Name) ||
                    f.Name == ConventionConverter.PublicToPrivate(property.Name, true));
        propertyBackingField.SetValue(obj, value);
    }
}

Which utilizes the ConventionConverter class which is implemented as follows :

public static class ConventionConverter
{
    public static string PublicToPrivate(string source, bool withUnderscore = false) => withUnderscore
        ? "_" + source[0].ToString().ToLower() + string.Join("", source.Skip(1))
        : source.First().ToString().ToLower() + string.Join("", source.Skip(1));

    public static string PrivateToPublic(string source, bool withUnderscore = false) => withUnderscore
        ? source.Remove(0, 1).First().ToString().ToUpper() + string.Join("", source.Skip(1))
        : source.First().ToString().ToUpper() + string.Join("", source.Skip(1));
}

If more options would be added a [Flags] enum would be more suitable.

Here are some test objects to work with :

public interface IAnimal
{
    Gender Gender { get; set; }
    string TellMeYourGender();
}

public class Dog : IAnimal
{
    public Gender Gender { get; set; }
    public string Breed { get; set; }
    public string TellMeYourGender()
    {
        return $"My gender is {Gender}";
    }
}

public class Cat : IAnimal
{
    public Gender Gender { get; set; }
    public string TellMeYourGender()
    {
        return $"My gender is {Gender}";
    }
}

public class Person
{
    private readonly int _age;
    public int Age => _age;

    private readonly string _name;
    public string Name => _name;
}

public class Employee : Person
{
    private readonly string workPlaceName;
    public string WorkPlaceName => workPlaceName;

    private int workHours;
    public int WorkHours => workHours;
}

And here is the example usage of the shown test objects :

static void Main(string[] args)
{
    //creating a builder that contains all properties from the class Person.
    ImmutableObjectBuilder<Person> humanBuilder = new ImmutableObjectBuilder<Person>();

    //instantiating a new Employee object using the builder.
    Employee John = humanBuilder
        .WithValue("Name", "John")
        .WithValue("Age", 32)
        .Build<Employee>();

    //instantiating a normal Person object.
    Person denis = new Person();

    //using the builder to edit the reference of the object.
    humanBuilder
        .WithValue("Name", "Denis")
        .WithValue("Age", 17)
        .Build(() => denis);

    //trying to edit a Employee specific property will crash
    //because the type of the builder is Person thus
    //only the properties of Person have been loaded.        
    Employee employee = humanBuilder
        .WithValue("Name", "Denis")
        .WithValue("Age", 17)
        .WithValue("WorkPlaceName", "SomePlace")
        .Build(() => new Employee()); //<- error

    //this will work because the builder is of type Employee
    //thus it will load all the properties from Employee.
    Employee workingEmployee = new ImmutableObjectBuilder<Employee>()
        .WithValue("Name", "John")
        .WithValue("Age", 32)
        .WithValue("WorkPlaceName", "SomePlace")
        .Build<Employee>();

    //creating a builder that contains all properties from the interface IAnimal.
    ImmutableObjectBuilder<IAnimal> animalBuilder = new ImmutableObjectBuilder<IAnimal>();

    //instantiating a new Dog object using the builder.
    Dog dog = animalBuilder
        .WithValue("Gender", Gender.Female)
        .Build<Dog>();

    //instantiating a normal cat object.
    IAnimal cat = new Cat();

    //using the builder to edit the reference of the object.
    animalBuilder
        .WithValue("Gender", Gender.Female)
        .Build(() => cat);

    //trying to edit a Employee specific property will crash
    //because the type of the builder is Person thus
    //only the properties of Person have been loaded.
    Dog dogo = animalBuilder
        .WithValue("Gender", Gender.Male)
        .WithValue("Breed", "German Shepard")
        .Build(() => new Dog()); //<- error
}
\$\endgroup\$
8
  • \$\begingroup\$ Not a fan of using strings for parameter names - you're shifting compile-time type checking to runtime.. Perhaps you could implement something like WithValue(t -> t.Name = "Denis")? \$\endgroup\$
    – Blorgbeard
    Dec 28, 2016 at 20:19
  • 1
    \$\begingroup\$ @Blorgbeard the properties have no setters. The object should be immutable. \$\endgroup\$
    – t3chb0t
    Dec 28, 2016 at 20:22
  • \$\begingroup\$ Ok, WithValue(t -> t.Name, "Denis") - and inspect the Expression to extract the property name. \$\endgroup\$
    – Blorgbeard
    Dec 28, 2016 at 20:24
  • \$\begingroup\$ @Blorgbeard this would of course work but the design had to be changed as the actual object is created after all property values have been provided. \$\endgroup\$
    – t3chb0t
    Dec 28, 2016 at 20:27
  • \$\begingroup\$ @denis Is this just an experiment or do you envisage a scenario where it would be useful? \$\endgroup\$
    – 404
    Dec 28, 2016 at 22:14

2 Answers 2

8
\$\begingroup\$

I would prefer the following syntax for its compile-time type-checking:

Employee John = humanBuilder
    .WithValue(t => t.Name, "John")
    .WithValue(t => t.Age, 32)
    .Build<Employee>();

Enabled by the following version of WithValue:

public ImmutableObjectBuilder<T> WithValue<TValue>(
       Expression<Func<T, TValue>> property, TValue value)
{
    var body = property.Body as MemberExpression;
    if (body == null)
    {
        throw new InvalidOperationException("Improperly formatted expression");
    }
    var propertyName = body.Member.Name;
    propertiesInfo[propertyName].Value = value;
    return this;
}

There is a requirement that the expression is of the form t => t.SimplePropertyAccess which cannot be enforced at compile-time, but at least you do get intellisense and compile-time type-checking.

\$\endgroup\$
7
  • \$\begingroup\$ Great, this is something I was looking for, thank you ! \$\endgroup\$
    – Denis
    Dec 28, 2016 at 20:52
  • \$\begingroup\$ This won't work as you need an instance of T to be able to select it's property this way. You'd have to instantiate the builded object first or use one that is in scope. \$\endgroup\$
    – t3chb0t
    Dec 28, 2016 at 20:58
  • 1
    \$\begingroup\$ No, you don't need an instance. We're inspecting an expression which acts on a T, not any specific instance of T. Try it, it works! \$\endgroup\$
    – Blorgbeard
    Dec 28, 2016 at 21:03
  • 1
    \$\begingroup\$ @t3chb0t t => t.Name t here is the generic parameter of the ImmutableBuilder<T> which allows us to access all of the properties inside that type T later when obtaining the body which is t => t.Name we extract the member which is .Name and we take the name of that member. \$\endgroup\$
    – Denis
    Dec 28, 2016 at 21:08
  • \$\begingroup\$ Ok, my bad. I guess I was thinkig of something else. \$\endgroup\$
    – t3chb0t
    Dec 28, 2016 at 21:16
5
\$\begingroup\$

I'm afraid a builder such as this is a dangerous thing. Field and property names are case sensitive. Changing their casing like the below extension could result in setting the wrong field or in case of different names not setting it at all. There is no guarantee that the backing field has a similar name as the property.

public static class ConventionConverter
{
    public static string PublicToPrivate(string source, bool withUnderscore = false) => withUnderscore
        ? "_" + source[0].ToString().ToLower() + string.Join("", source.Skip(1))
        : source.First().ToString().ToLower() + string.Join("", source.Skip(1));

    public static string PrivateToPublic(string source, bool withUnderscore = false) => withUnderscore
        ? source.Remove(0, 1).First().ToString().ToUpper() + string.Join("", source.Skip(1))
        : source.First().ToString().ToUpper() + string.Join("", source.Skip(1));
}

Consider this:

class Employee
{
    private readonly List<int> _itemList1;
    private readonly List<int> _ItemList2;

    public IEnumerable<int> Items => _ItemList2.AsReadOnly();
}

You cannot rely on private members as they actaully do not have to follow any conventions. The naming conventions etc. apply only to public APIs.

What would happen if I had two fields with names that have different casing? The builder would set only the first field:

allFields.FirstOrDefault(..)

But how do you know whether this is the right one?


Maybe one more example:

class Person
{
    private readonly string _firstName;
    private readonly string _lastName;

    public string FullName => $"{_firstName}, {_lastName}";

   // other properties...
}

What would happen in this case? A property can construct it's value from several backing fields or even call methods.

But you already know it as one of ther requirements is:

The properties that you want to be modified must have a backing readonly field.

It means that the user needs to know quite a lot about the internal implementation of each class/property to be able to use the builder properly. If he changes some property implementation then the build process might break somewhere at runtime which are the worst bugs to debug. I wouldn't use it for production code.

\$\endgroup\$
4
  • \$\begingroup\$ allFields.FirstOrDefault(..) I'm aware of that but I prefer to believe that whoever is using the code would follow the 2 simple rules. \$\endgroup\$
    – Denis
    Dec 28, 2016 at 20:47
  • 1
    \$\begingroup\$ @denis this is a very optimistinc point of view. So you rely only on users who has been to CR and know how to write their code? ;-) \$\endgroup\$
    – t3chb0t
    Dec 28, 2016 at 20:50
  • \$\begingroup\$ I don't see any other way to make it work for immutable objects too. \$\endgroup\$
    – Denis
    Dec 28, 2016 at 20:51
  • \$\begingroup\$ Good points there I didn't consider that. I guess it's not practical for creating immutable objects, but it will still do for mutable ones. \$\endgroup\$
    – Denis
    Dec 28, 2016 at 21:09

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.