[Journal - Universal Callbacks]

Universal Callbacks

Sunday, February 26, 2006

The CHandler class of Gregor.Core, which is a universal handler for any event that follows the common event signature pattern used in dynamic scenarios, has attracted a more general companion that allows routing any callback to a single handler, also for dynamic scenarios: CCallback(Of DelegateType).

Taking a Test Drive

Here's some test code. We create three instances of the generic CCallback class. These objects wrap delegates pointing to private methods in that class.

The delegate types used for binding CCallback are System.EventHandler, two closed constructions of the Gregor.Core.Callback generic delegate, respectively:

delegate void EventHandler(object sender, EventArgs e);
delegate string Callback(string arg0); // Callback<string, string>>;
delegate int Callback(); // Callback<int>;

As for the delegates created (they're exposed through the HandlerDelegate property), we simply use them for invocation right away, assuming the role of the event sender for the purpose of this demo:

private static void TestUniversalCallback(){
    
    CCallback<EventHandler> cb1 =
        CCallback<EventHandler>.Create();
    cb1.CallbackInvoked += HandleCallbackInvoked1;
    EventHandler handler1 = cb1.HandlerDelegate;
    handler1(null, EventArgs.Empty);

    Dev.Trace("===");

    CCallback<Callback<string, string>> cb2 =
        CCallback<Callback<string, string>>.Create();
    cb2.CallbackInvoked += HandleCallbackInvoked2;
    cb2.UseTypeConversions = true;
    Callback<string, string> handler2 = cb2.HandlerDelegate;
    string sRet2 = handler2("Here it is.");
    Dev.Trace("---");
    Dev.Trace(sRet2);

    Dev.Trace("===");

    CCallback<Callback<int>> cb3 =
        CCallback<Callback<int>>.Create();
    cb3.CallbackInvoked += HandleCallbackInvoked3;
    cb3.UseTypeConversions = true;
    Callback<int> handler3 = cb3.HandlerDelegate;
    int iRet3 = handler3();
    Dev.Trace("---");
    Dev.Trace(iRet3);
}

We subscribe to the CallbackInvoked event of these objects, tracing parameters and setting a return value.

private static void HandleCallbackInvoked1(object sender,
                                           CCallbackEventArgs e){
    Dev.Trace(sender);
    Dev.TraceCollection(e.Arguments);
    e.ReturnValue = "Going nowhere.";
}

private static void HandleCallbackInvoked2(object sender,
                                           CCallbackEventArgs e){
    Dev.Trace(sender);
    Dev.TraceCollection(e.Arguments);
    e.ReturnValue = 1; // type conversion needed
}

private static void HandleCallbackInvoked3(object sender,
                                           CCallbackEventArgs e){
    Dev.Trace(sender);
    Dev.TraceCollection(e.Arguments);
    e.ReturnValue = null; // type conversion needed
}

In the second and third cases, we receive the return value set by the corresponding event handler, and trace it. Here's the output:

Callback<System.EventHandler> {}
2 elements in System.Object[]
  null
  System.EventArgs
===
Callback<Gregor.Core.Callback<System.String, System.String>> {}
1 elements in System.Object[]
  Here it is.
---
1
===
Callback<Gregor.Core.Callback<System.Int32>> {}
0 elements in System.Object[]
---
0

Inside CCallback

Basically, CCallback has a set of generically overloaded private handler methods that are constructed so as to match the delegate's signature. This is done with MethodInfo.MakeGenericMethod(). The so constructed handler methods are used to create a delegate pointing to them, which is available througth the HandlerDelegate property. The handler methods fire the CallbackInvoked event.

The code to create the delegate is in CCallback.Init(). Here's a demo version of it: it is simplified in that it assumes a non-void-returning delegate type with two parameters, and denormalizes and sanitizes the various wrappers in Gregor.Core routines for the sake of demonstrating the general approach of constructing a generic method for a given delegate type:

// get the delegate type object
Type tpDel = typeof(DelegateType);

// get type arguments: return type and parameter types
MethodInfo miInvoke = tpDel.GetMethod("Invoke");
ParameterInfo[] parms = miInvoke.GetParameters();
Type[] typeArgs = new Type[1 + parms.Length];
typeArgs[0] = miInvoke.ReturnType;
for(int i = 0; i < parms.Length; i++){
    typeArgs[i + 1] = parms[i].ParameterType;
}

// get the generic method definition that fits the number of parameters
MethodInfo miGeneric = this.GetType().GetMethod("HandleIt2");

// construct the actual handler method
MethodInfo miHandler = miGeneric.MakeGenericMethod(typeArgs);

// with the constructed handler method, instantiate the delegate
Delegate del = Delegate.CreateDelegate(tpDel, this, miHandler);
return (DelegateType) del;

CCallback vs. CHandler

One difference between CHandler and CCallback is that the latter does not use the delegate it creates for anything else than making it available through the HandlerDelegate property (CHandler assigns the delegate to the event to handle). Client code may then use the delegate as it sees fits, such as assigning it to a property or passing it as a method argument, possibly combining it with other delegates.

Another difference is that the handler method is always a private method of CCallback. That is because the whole point of the class is to allow for delegate signatures that differ to a greater degree than those of event handler types: Any delegate type can be instantiated with one of the ten private handler methods, as long as it has no more than five parameters. Client code may then listen to the CallbackInvoked event, which provides access to arguments and allows setting the return value (where applicable).

Universal Callbacks

The CallbackInvoked event passes the data in a non-generic fashion, using System.Object only (see the CCallbackEventArgs class). This is because the whole point is to have a universal, central handler routine.

A situation where on would want to handle a variety of callbacks in a single routine is in dynamic scenarios. For example, .NET Console lets the user define User Functions, which are interpreted. So there is a need to bridge the gap between a .NET method existing as IL (which is the only thing that can be an actual callback), and user code that's merely interpreted. In .NET Console, that's done with the routines in the UserCallback module. See there for an example. Actually, .NET Console User Functions are even more universal due to option varargs.

The Signature Issue

By constructing generic methods at runtime (ie., binding a generic method definition to the types used by the delegate dynamically), we can avoid the problem of return and parameter type variance entirely.

Otherwise, we'd have to use handler methods that use nothing but System.Object. On delegate instantiation, we'd have to allow something I'd call return type contravariance [sic]. Parameter type contravariance wouldn't be a problem here, since any code invoked uses System.Object for the parameters (possibly wrapped in a CCallbackEventArgs object). However, we cannot guarantee return type covariance: if the delegate type declares a return type other than void or System.Object, the internal handler method had better return a reference type assignable to that type.

So, with non-generic handler methods, the problem could not be solved with the usual mechanism of delegate relaxation, as implemented in Reflect.CreateDelegate() and Reflect.IsMethodCompatible(), or in System.Delegate.CreateDelegate() - all of which are dynamic equivalents of C# method group conversions. Rather, client code would have to return a reference that is actually covariant delegate's declared return type. This requirement would have to be checked in CCallback's code.

However, since client code deals with System.Object only - remember, the whole point is about dynamic scenarios where callbacks are to be handled centrally - there is still some need for type checking after the client-provided event handler returns. In particular, if the delegate's return type is a value type, null references may be converted to that type's default value (depending on the UseTypeConversions property); in any other case, assignability is checked, and conversions may be performed as well.

Return value checking is another reason why the handler methods are always private to CCallback, unlike those in CHandler.

Final Thoughts

Using MethodInfo.MakeGenericMethod(), with a bit of generic overloading of methods, is an alternative to using Reflection.Emit to dynamically creating methods.

Oh yes, here is another situation where a special type constraint (where T : delegate) is needed, but not supported by the CLR.