Uniqueness of an Action in C#

257 Views Asked by At

I need to store Action<T> in a ConcurrentDictionary and I am wrangling my head around the question:

What identifies an action as unique and how to store it in the dictionary so the dictionary ends up without duplicates?

In my scenario uniqueness means if two instances of a class add the action to the dictionary they are unique.

A static method can be added only once.

Thoughts I had to identify the uniqueness (aka answer for "what have you tried so far?")

Thought 1:

My first approach has been to store the action directly but the compiler told me it isn't allowed due to the mismatch between generics Action<T> and the definition of the dictionary ConcurrentDictionary<Action<ISomething>, string>.

So I tried to flip key and value but what to take as key unique key then?

Thought 2

Using action.GetHashCode() may result in conflicts.

Thought 3

If I go with action.Method.DeclaringType plus action.Method.Name both would have the same key.

If I go with action.Target.GetType().FullName + action.Method.Name it won't work because the action can be static and action.Taget will be null.


Provide some code: Please feel free to copy paste this executable sample into a .NET6 ConsoleApplication template Program.cs file within Visual Studio 2022.

See the method Container.Add to find my problem.

using System.Collections.Concurrent;
using System.Diagnostics.Metrics;

namespace UniquenessOfActionsInCSharp
{

    public interface IContainer
    {
        void Add<T>(Action<T> action) where T : ISomething;
        void Remove<T>(Action<T> action) where T : ISomething;
        void Share<T>(T something) where T : ISomething;
    }

    /// <summary>
    /// Given is a container class holding a dictionary of actions.
    /// </summary>
    public class Container : IContainer
    {

        //protected readonly ConcurrentDictionary<Action<ISomething>, string> InternalDict = new();
        protected readonly ConcurrentDictionary<Type, ConcurrentDictionary<string, Action<ISomething>>> InternalDict = new();
        protected readonly ConcurrentQueue<ISomething> InternalQueue = new ConcurrentQueue<ISomething>();

        // returns the amount of added elements
        public int Count<T>() => InternalDict.TryGetValue(typeof(T), out var innerDict) ? innerDict.Count : 0;

        // adds an element if it is not already added
        // and yes you need to leave the signature as it is
        public void Add<T>(Action<T> action) where T : ISomething
        {
            // check uniqueness of an action and only add to the InternalDict if it is not already added
            // TODO: the question is how to implement this method
            
            //InternalSet.Add((x) => action((T)x));
        }

        public void Remove<T>(Action<T> action) where T : ISomething {}

        
        public void Share<T>(T something) where T : ISomething
        {
            // add something to a queue
            // start BackgroundJob for invoking actions added to the given type
        }

        // iterates over all added elements
        protected void BackgroundJob()
        {
            while (InternalQueue.TryDequeue(out ISomething something))
            {
                if (InternalDict.TryGetValue(something.GetType(), out var innerDict))
                {
                    foreach (var kvp in innerDict)
                    {
                        kvp.Value(something);
                    }
                }
            }
        }

    }

    // there are multiple implementations of ISomething
    public interface ISomething
    {
        string Foo { get; set; }
    }

    // but for the sake of simplicity I just added SomethingA
    public class SomethingA : ISomething
    {
        public string Foo { get; set; } = "Bar";
        // some other properties (different to each implementation)
    }

    public class SomethingB : ISomething
    {
        public string Foo { get; set; } = "Foo";
    }

    // some class providing the actions that should be added to the dictionary
    public class Registrant
    {

        public static int StaticCount { get; private set; }

        public int CountA { get; private set; }
        public int CountB { get; private set; }

        public static void TheStaticAction(SomethingA a) { StaticCount++; }

        public void TheActionA(SomethingA a) { CountA++; }
        public void TheActionB(SomethingB b) { CountB++; }
    }

    // an executable code sample for those who mutters if it isn't there
    public class Program
    {

        // the use case
        static void Main(string[] args)
        {

            // create the setup
            Container  container = new Container();
            Registrant instance1 = new Registrant();
            Registrant instance2 = new Registrant();
            Registrant instance3 = new Registrant();

            // do the add calls and check state

                // add 1: valid
        container.Add<SomethingA>(instance1.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 1} > instance1.TheActionA<SomethingA>(...) added");

        // add 2: invalid (the action is already registered)
        container.Add<SomethingA>(instance1.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 1} > instance1.TheActionA<SomethingA>(...) skipped");

        // add 3: valid (same method of a class but different instance of the class)
        container.Add<SomethingA>(instance2.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 2} > instance1.TheActionA<SomethingA>(...) added");

        // add 4: invalid (the action is already registered)
        container.Add<SomethingA>(instance2.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 2} > instance1.TheActionA<SomethingA>(...) skipped");

        // add 5: valid
        container.Add<SomethingA>(Registrant.TheStaticAction);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 3} > Registrant.TheStaticAction<SomethingA>(...) added");

        // add 6: invalid (static methods can't be added twice)
        container.Add<SomethingA>(Registrant.TheStaticAction);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 3} > Registrant.TheStaticAction<SomethingA>(...) skipped");

        // add 7: valid (same method of a class but different instance of the class)
        container.Add<SomethingB>(instance3.TheActionB);
        Console.WriteLine($"valid: {container.Count<SomethingB>() == 1} > instance1.TheAction<SomethingB>(...) added");

        // add 8: invalid (the action is already registered)
        container.Add<SomethingB>(instance3.TheActionB);
        Console.WriteLine($"valid: {container.Count<SomethingB>() == 1} > instance1.TheAction<SomethingB>(...) skipped");


        // invoking
        container.Share(new SomethingB());
        container.Share(new SomethingA());

        Thread.Sleep(5000);

        // and cross checking (all actions called only once though tried to add them twice)
        Console.WriteLine($"valid: {instance1.CountA == 1 && instance1.CountB == 0} > instance1.CountA == {instance1.CountA} && instance1.CountB == {instance1.CountB}");
        Console.WriteLine($"valid: {instance2.CountA == 1 && instance2.CountB == 0} > instance2.CountA == {instance2.CountA} && instance2.CountB == {instance2.CountB}");
        Console.WriteLine($"valid: {Registrant.StaticCount == 1} > Registrant.StaticCount == {Registrant.StaticCount}");
        Console.WriteLine($"valid: {instance3.CountA == 0 && instance3.CountB == 1} > instance3.CountA = {instance3.CountA} && instance3.CountB == {instance3.CountB}");


        }

    }

}

If the console output writes "valid: true >" in each line my question is answered.


The hashset approach

enter image description here

I can add with

 InternalSet.Add((x) => action((T)x));

but losing all chance for checking uniqueness. So I decided for a CONCURRENT dictionary where I need some key.


I don't care which collection is used.

I don't care how concurrency is handled as long it is handled.

It is not allowed to change the interface removing the generic.

by the way I already have an working solution in my code using a dictionary but I am asking to may find better solutions because I am not satisfied with my current code.


2

There are 2 best solutions below

5
Charlieface On BEST ANSWER

It looks to me that you cannot store a Action<ISomething>, because you don't know what exact type the Action will take as a parameter. You can only upcast it to a more derived ISomething, not downcast it to an interface.

So instead we can just declare it as Delegate (which has equality functions built in), and we can cast it to T because we know we will get the right one from the dictionary using typeof(T).

In order to call this, we could use reflection. But a much better option is to instead store with each delegate, a lambda that knows how to cast an ISomething to T. So each one gets stored in the inner dictionary as a pair of Delegate, Action<ISomething>, and the lambda is created simply as obj => action((T)obj).

public class Container : IContainer
{
    protected readonly ConcurrentDictionary<Type, ConcurrentDictionary<Delegate, Action<ISomething>>> InternalDict = new();
    protected readonly ConcurrentQueue<ISomething> InternalQueue = new ConcurrentQueue<ISomething>();

    // returns the amount of added elements
    public int Count<T>() => InternalDict.TryGetValue(typeof(T), out var innerDict) ? innerDict.Count : 0;

    // adds an element if it is not already added
    public void Add<T>(Action<T> action) where T : ISomething
    {
        InternalDict.AddOrUpdate(typeof(T),
            (key, arg) => new ConcurrentDictionary<Delegate, Action<ISomething>>(new[] { arg }),
            (key, innerDict, arg) => {
                innerDict.TryAdd(arg.Key, arg.Value);
                return innerDict;
            },
            new KeyValuePair<Delegate, Action<ISomething>>(action, obj => action((T)obj))
        );
    }

    public void Remove<T>(Action<T> action) where T : ISomething
    {
        if (InternalDict.TryGetValue(typeof(T), out var innerDict))
        {
            innerDict.TryRemove(action, out var actor);
        }
    }
    
    public void Share<T>(T something) where T : ISomething
    {
        InternalQueue.Enqueue(something);
        // start BackgroundJob for invoking actions added to the given type
        // unclear what should go here, maybe a Task.Run??
    }

    // iterates over all added elements
    protected void BackgroundJob()
    {
        while (InternalQueue.TryDequeue(out ISomething something))
        {
            if (InternalDict.TryGetValue(something.GetType(), out var innerDict))
            {
                foreach (var kvp in innerDict)
                {
                    kvp.Value(something);
                }
            }
        }
    }
}

Note that something.GetType() may not return a result from your dictionary if there is a multi-level inheritance tree. So you may need to recursively go down the inheritance stack

    protected void BackgroundJob()
    {
        while (InternalQueue.TryDequeue(out ISomething something))
        {
            for (var type = something.GetType(); type != typeof(object); type = type.BaseType)
            {
                if (InternalDict.TryGetValue(something.GetType(), out var innerDict))
                {
                    foreach (var kvp in innerDict)
                    {
                        kvp.Value(something);
                    }
                    break;  // continue the while loop
                }
            }
        }
    }
2
Progman On

The Action<T> delegate already implements an Equals() method which fits your requirement:

The methods and targets are compared for equality as follows:

  • If the two methods being compared are both static and are the same method on the same class, the methods are considered equal and the targets are also considered equal.

  • If the two methods being compared are instance methods and are the same method on the same object, the methods are considered equal and the targets are also considered equal.

  • Otherwise, the methods are not considered to be equal and the targets are also not considered to be equal.

This means that in your example, the action instance1.TheAction is not the same as the instance2.TheAction action, because they reference different targets (instance1 and instance2 reference different instances). And these actions are not equal to the Registrant.TheStaticAction action, because they don't reference the same method (TheAction vs. TheStaticAction) as well as not reference the same target (instance1 vs. null).

This allows you to simply use a ISet<Delegate>, which doesn't allow adding a second instance which is equal with an element already in the set. If you change the type of the InternalDict field to a ISet you will get your desired requirements. See the following code:

public interface IContainer
{
    void Add<T>(Action<T> action) where T : ISomething;
    void Remove<T>(Action<T> action) where T : ISomething;
    void Share<T>(T something) where T : ISomething;
}

/// <summary>
/// Given is a container class holding a dictionary of actions.
/// </summary>
public class Container : IContainer
{

    protected readonly ISet<Delegate> actions = new HashSet<Delegate>();

    protected readonly ConcurrentQueue<ISomething> InternalQueue = new ConcurrentQueue<ISomething>();

    // returns the amount of added elements
    public int Count<T>()
    {
        return actions.OfType<Action<T>>().Count();
    }
    
    // adds an element if it is not already added
    // and yes you need to leave the signature as it is
    public void Add<T>(Action<T> action) where T : ISomething
    {
        actions.Add(action);
    }

    public void Remove<T>(Action<T> action) where T : ISomething
    {
        actions.Remove(action);
    }

    
    public void Share<T>(T something) where T : ISomething
    {
        // add something to a queue
        // start BackgroundJob for invoking actions added to the given type
        InternalQueue.Enqueue(something);
    }

    // iterates over all added elements
    public void BackgroundJob()
    {
        while (InternalQueue.TryDequeue(out ISomething something))
        {
            //Console.WriteLine("Got a: "+something);
            foreach (Delegate entry in this.actions)
            {
                ParameterInfo[] parameters = entry.Method.GetParameters();
                ParameterInfo parameter = parameters[0];
                Type parameterType = parameter.ParameterType;
                if (parameterType == something.GetType())
                {
                    //Console.WriteLine("Match, call it");
                    entry.DynamicInvoke(something);
                }
            }
        }
    }

}

// there are multiple implementations of ISomething
public interface ISomething
{
    string Foo { get; set; }
}

// but for the sake of simplicity I just added SomethingA
public class SomethingA : ISomething
{
    public string Foo { get; set; } = "Bar";
    // some other properties (different to each implementation)
}

public class SomethingB : ISomething
{
    public string Foo { get; set; } = "Foo";
}

// some class providing the actions that should be added to the dictionary
public class Registrant
{

    public static int StaticCount { get; private set; }

    public int CountA { get; private set; }
    public int CountB { get; private set; }

    public static void TheStaticAction(SomethingA a) { StaticCount++; }

    public void TheActionA(SomethingA a) { CountA++; }
    public void TheActionB(SomethingB b) { CountB++; }
}

// an executable code sample for those who mutters if it isn't there
public class Program
{

    // the use case
    static void Main(string[] args)
    {

        // create the setup
        Container  container = new Container();
        Registrant instance1 = new Registrant();
        Registrant instance2 = new Registrant();
        Registrant instance3 = new Registrant();

        // do the add calls and check state

        // add 1: valid
        container.Add<SomethingA>(instance1.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 1} > instance1.TheActionA<SomethingA>(...) added");

        // add 2: invalid (the action is already registered)
        container.Add<SomethingA>(instance1.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 1} > instance1.TheActionA<SomethingA>(...) skipped");

        // add 3: valid (same method of a class but different instance of the class)
        container.Add<SomethingA>(instance2.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 2} > instance1.TheActionA<SomethingA>(...) added");

        // add 4: invalid (the action is already registered)
        container.Add<SomethingA>(instance2.TheActionA);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 2} > instance1.TheActionA<SomethingA>(...) skipped");

        // add 5: valid
        container.Add<SomethingA>(Registrant.TheStaticAction);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 3} > Registrant.TheStaticAction<SomethingA>(...) added");

        // add 6: invalid (static methods can't be added twice)
        container.Add<SomethingA>(Registrant.TheStaticAction);
        Console.WriteLine($"valid: {container.Count<SomethingA>() == 3} > Registrant.TheStaticAction<SomethingA>(...) skipped");

        // add 7: valid (same method of a class but different instance of the class)
        container.Add<SomethingB>(instance3.TheActionB);
        Console.WriteLine($"valid: {container.Count<SomethingB>() == 1} > instance1.TheAction<SomethingB>(...) added");

        // add 8: invalid (the action is already registered)
        container.Add<SomethingB>(instance3.TheActionB);
        Console.WriteLine($"valid: {container.Count<SomethingB>() == 1} > instance1.TheAction<SomethingB>(...) skipped");

        // invoking
        container.Share(new SomethingB());
        container.Share(new SomethingA());

        container.BackgroundJob();

        // and cross checking (all actions called only once though tried to add them twice)
        Console.WriteLine($"valid: {instance1.CountA == 1 && instance1.CountB == 0} > instance1.CountA == 1 && instance1.CountB == 0");
        Console.WriteLine($"valid: {instance2.CountA == 1 && instance2.CountB == 0} > instance2.CountA == 1 && instance2.CountB == 0");
        Console.WriteLine($"valid: {Registrant.StaticCount == 1} > Registrant.StaticCount == 1");
        Console.WriteLine($"valid: {instance3.CountA == 0 && instance3.CountB == 1} > instance3.CountA == 0 && instance3.CountB == 1");

    }

}

(For testing purposes, I have made the BackgroundJob() method public)

The BackgroundJob() method is a little bit more complicated since you have to search for the right Action<T>/Delegate to use. This implementation will look at the argument type of the first parameter of the method and compare it against the type of the next queue item. If it is a match, it will execute the action/delegate.

When you run this code you will get the following output:

valid: True > instance1.TheActionA<SomethingA>(...) skipped
valid: True > instance1.TheActionA<SomethingA>(...) added
valid: True > instance1.TheActionA<SomethingA>(...) skipped
valid: True > Registrant.TheStaticAction<SomethingA>(...) added
valid: True > Registrant.TheStaticAction<SomethingA>(...) skipped
valid: True > instance1.TheAction<SomethingB>(...) added
valid: True > instance1.TheAction<SomethingB>(...) skipped
valid: True > instance1.CountA == 1 && instance1.CountB == 0
valid: True > instance2.CountA == 1 && instance2.CountB == 0
valid: True > Registrant.StaticCount == 1
valid: True > instance3.CountA == 0 && instance3.CountB == 1

As you see, all your asserts are true.

You can add lock blocks to handle concurrencies depending on your specific requirements. A simple lock around each access would work, which will fulfill your requirement:

I don't care how concurrency is handled as long it is handled.