mercredi 18 novembre 2020

Passing the context around in a C# class library, looking for an "easy" way without using static

For a library (.NET Standard 2.0), I designed some classes that look roughly like this:

public class MyContext
{
    // wraps something important. 
    // It can be a native resource, a connection to an external system, you name it.
}

public abstract class Base
{
    public Base(MyContext context)
    {
        Context = context;
    }

    protected MyContext Context { get; private set; }

    // methods
}

public class C1 : Base
{
    public C1(MyContext context, string x)
        : base(context)
    {
        // do something with x
    }

    // methods
}

public class C2 : Base
{
    public C2(MyContext context, long k, IEnumerable<C1> list)
        : base(context)
    {
        // do something with the list of C1s.
    }

    // methods
}

public class C3 : Base
{
    public C3(MyContext context, int y, double z)
        : base(context)
    {
        // do something with y and z.
    }

    public void DoSomething()
    {
        var something = new C2(this.Context, 2036854775807, new[]
            {
            new C1(this.Context, "blah"),
            new C1(this.Context, "blahblah"),
            new C1(this.Context, "blahblahblah"),
            }
            );
    }

    // other methods
}

// other similar classes

The classes were criticized because of that "MyContext" parameter that every constructor requires. Some people say that it clutters the code.

I said that the parameter is needed to propagate the context, so that e.g. when you call the DoSomething method of C3, the contained instances of C1 and C2 all share the same context.

A potential alternative is defining the parameter as "Base" instead of "MyContext", e.g.:

public abstract class Base
{
    public Base(Base b) // LOOKS like a copy constructor, but it isn't!
    {
        Context = b.Context;
    }

    protected MyContext Context { get; private set; }

    // methods
}

public class C1 : Base
{
    public C1(Base b, string x)
        : base(b)
    {
        // do something with x
    }

    // methods
}

public class C2 : Base
{
    public C2(Base b, long k, IEnumerable<C1> list)
        : base(b)
    {
        // do something with the list of C1s.
    }

    // methods
}

public class C3 : Base
{
    public C3(Base b, int y, double z)
        : base(b)
    {
    }

    public void DoSomething()
    {
        var something = new C2(this, 2036854775807, new[]
            {
            new C1(this, "blah"),
            new C1(this, "blahblah"),
            new C1(this, "blahblahblah"),
            }
            );
    }

    // other methods
}

// other similar classes

Now the parameter you must pass is shorter, but it still raised some eyebrows.

(And I don't like it very much, because logically a C1/C2/C3/etc object does not require a "Base", it requires a MyContext. Not to mention that constructor that seems a copy constructor, but it is not! Oh, and now how can I initialize C1, C2 or C3 outside a Base-derived class, since all of them want a Base, and Base is abstract? I need to "bootstrap" the system somewhere... I guess all classes may have two constructors, one with Base and one with MyContext ... but having two constructors for each class may be a nightmare for future maintainers...).

Some people said "Why don't you turn MyContext into a singleton, like in Project X?". Project X has a similar class structure but, "thanks" to the singleton, classes don't have a MyContext parameter: they "just work". It's magic! I don't want to use a singleton (or static data in general), because:

  • singletons are actually global variables, global variables are a bad programming practice. I used global variables many times, and many times I regretted my decision, so now I prefer to avoid global variables if possible.
  • with a singleton, classes share a context, but only ONE context: you cannot have MULTIPLE contexts. I find it too limiting for a library that will be used by an unspecified number of people in the company.

Some people said that we should not be too anchored to "academic theory", useless ideas of "good" and "bad" programming practices, that we should not overengineer classes in the name of abstract "principles". Some people said that we should not fall into the trap of "passing around ginormous context objects". Some people said YAGNI: they're pretty sure that 99% of the usage will involve exactly one context.

I don't want to anger that 1% by disallowing their use case, but I don't want to frustrate the other 99% with classes that are difficult to use.

So I thought about a way to have the cake and eat it. For example: MyContext is concrete but it ALSO has a static instance of itself. All classes (Base, C1, C2, C3, etc) have two constructors: one with a MyContext concrete parameter, the other one without it (and it reads from the static MyContext.GlobalInstance). I like it... and at the same time I don't, because again it requires to maintain two constructors for each class, and I'm afraid it can be too error-prone (use the incorrect overload just once and the entire structure collapses, and you find out only at runtime). So forget for the moment the "static AND non static" idea.

I tried to imagine something like this:

public abstract class Base
{
    public Base(MyContext context)
    {
        Context = context;
    }

    protected MyContext Context { get; private set; }


    public T Make<T>(params object[] args) where T : Base
    {
        object[] arrayWithTheContext = new[] { this.Context };
        T instance = (T)Activator.CreateInstance(typeof(T),
            arrayWithTheContext.Concat(args).ToArray());
        return instance;
    }

    // other methods
}

public class C1 : Base
{
    public C1(MyContext context, string x)
        : base(context)
    {
        // do something with x
    }

    // methods
}

public class C2 : Base
{
    public C2(MyContext context, long k, IEnumerable<C1> list)
        : base(context)
    {
        // do something with the list of C1s.
    }

    // methods
}

public class C3 : Base
{
    public C3(MyContext context, int y, double z)
        : base(context)
    {
        // do something with y and z.
    }

    public void DoSomething()
    {
        var something = Make<C2>(2036854775807, new[]
            {
            Make<C1>("blah"),
            Make<C1>("blahblah"),
            Make<C1>("blahblahblah"),
            }
            );
    }

    // other methods
}

// other similar classes

The calls to Make LOOK better, but they are even more error-prone, because the signature of Make is not strongly-typed. My goal is to simplify doing stuff for the users. If I opened a parenthesis and Intellisense proposed me ... an array of System.Object, I'd be VERY frustrated. I could pass incorrect parameter and I would find out only at runtime (Since good old .NET Framework 2.0, I hoped to forget about arrays of "System.Object"...)

So what? A factory with a dedicated, strongly-typed method for each Base-derived class would be type-safe, but maybe it would also be "ugly" to see (and a textbook violation of the open-close principle. Yes, "principles", again):

public class KnowItAllFactory
{
    private MyContext _context;

    public KnowItAllFactory(MyContext context) { _context = context; }

    public C1 MakeC1(string x) { return new C1(_context, x); }
    public C2 MakeC2(long k, IEnumerable<C1> list) { return new C2(_context, k, list); }
    public C3 MakeC3(int y, double z) { return new C3(_context, y, z); }
    // and so on
}

public abstract class Base
{
    public Base(MyContext context)
    {
        Factory = new KnowItAllFactory(context);
    }

    protected MyContext Context { get; private set; }
    protected KnowItAllFactory Factory { get; private set; }

    // other methods
}

public class C1 : Base
{
    public C1(MyContext context, string x)
        : base(context)
    {
        // do something with x
    }

    // methods
}

public class C2 : Base
{
    public C2(MyContext context, long k, IEnumerable<C1> list)
        : base(context)
    {
        // do something with the list of C1s.
    }

    // methods
}

public class C3 : Base
{
    public C3(MyContext context, int y, double z)
        : base(context)
    {
        // do something with y and z.
    }

    public void DoSomething()
    {
        var something = Factory.MakeC2(2036854775807, new[]
            {
            Factory.MakeC1("blah"),
            Factory.MakeC1("blahblah"),
            Factory.MakeC1("blahblahblah"),
            }
            );
    }

    // other methods
}

// other similar classes

Aucun commentaire:

Enregistrer un commentaire