|
This article presents a simple but effective programming technique that allows to implement object-oriented callback mechanisms between two programming languages - Ada and C++. The basic use-case for this technique is when a C++ library that provides the callback functionality is accessed from Ada.
The technique presented here was developed in the context of the YAMI4 library, which overall structure is presented here:
The YAMI4 library is multilingual and tries to reuse code in a layered architecture, where a single core component written in C++ serves as a foundation for high-level components in Ada and C++. It is the binding between Ada and C++ modules that is of interest here in the context of polymorphic callbacks.
The YAMI4 messaging library uses callbacks to user-provided handlers in order to inform about important events - message progress notifications, incoming messages or connection status changes are possible events that are reported this way. For design reasons it is beneficial to expose such callback mechanisms in terms of interfaces that are implemented by the user, which gives them their object-oriented flavour - the polymorphic callback in this context means that the callback invocation itself uses dynamic dispatch in order to locate and execute the appropriate event handler implementation.
It is important to note that the problem of callbacks is not particular to messaging systems. Similar interfacing patterns can be found in other systems as well - whether this is a web server passing requests to request handler, a database server calling its stored procedures, a GUI library invoking user actions or an alarm system passing event notifications to reactive components, the polymorphic callback can be a viable solution pattern. The problem has a relatively general nature and that's why it is possible to extract it in the form that is no longer related to the original application context.
The code examples presented here are self-contained and can be applied or extended in other application contexts.
To explain the problem in concrete terms, the following code presents the base C++ component that contains a simple notification mechanism with the callback interface:
// // base.h // class Callback { public: virtual void call() = 0; }; void registerCallback(Callback * c); void fireAll(); // // base.cpp // #include "base.h" #include <cstdio> #include <vector> std::vector<Callback *> allCallbacks; void registerCallback(Callback * c) { std::puts("base: register callback"); allCallbacks.push_back(c); } void fireAll() { std::puts("base: fire all!"); for (std::vector<Callback *>::iterator it = allCallbacks.begin(); it != allCallbacks.end(); ++it) { (*it)->call(); } }
The problem here is to implement the Ada binding for this base component while preserving the object-oriented nature of the notification mechanism.
In order to implement the inter-language binding with callback interface it is worth to review the anatomy of the callback itself.
Object-oriented invocations, even though considered to be atomic or indivisible concepts, can be presented as pairs that combine an object and an action - the object represents the who, whereas the action represents the what of the invocation.
Incidentally both of these constituents can be expressed in terms of pointers or addresses - this makes them sufficiently low-level so that they can exist within the framework of the Ada/C++ binding, which in the Ada language standard is defined at the level of C language constructs. That is, since the C language is a common denominator of Ada and C++ for the purpose of language binding, expressing the callback as a pair of pointers is a necessary translation step - and that translation has to be performed on both Ada and C++ sides. Taking these additional translation layers into account, the final architecture for this solution consists of four layers:
Coming from the bottom up, the translation layer at the C++ side can be implemented as follows:
// // wrapper.cpp // #include "base.h" extern "C" typedef void (*CallbackFunctionType)(void *); class WrappedCallback : public Callback { public: WrappedCallback(CallbackFunctionType function, void * object) : f_(function), obj_(object) {} virtual void call() { // call into the Ada translator procedure f_(obj_); } private: CallbackFunctionType f_; void * obj_; }; extern "C" void wrapped_registerCallback(void * function_addr, void * object) { // brute-force conversion from raw procedure address obtained from Ada // to function pointer that is useable at the C++ level union { void * raw_pointer; CallbackFunctionType function_pointer; } converter; converter.raw_pointer = function_addr; CallbackFunctionType function = converter.function_pointer; registerCallback(new WrappedCallback(function, object)); } extern "C" void wrapped_fireAll() { fireAll(); }
The wrapper layer above consists of three definitions with the "C" convention - the callback function pointer type, which can carry the translated address of the action to be executed on the Ada side, and two wrappers for the notification mechanism.
The most important part here is the registration function, which has to convert the procedure address passed by the Ada layer to the genuine function pointer. Interestingly, converting raw pointers to function pointers is not obvious in C++ and above the union is used as the most low-level way to achieve that goal. Together with the class wrapper that implements the base interface, this translation layer can pass callback invocations up to the higher layers, where they are handled by Ada code.
The translation layer in Ada has the form of the following package:
-- -- callbacks.ads -- package Callbacks is type Callback is interface; type Callback_Access is access all Callback'Class; procedure Call (Self : in Callback) is abstract; procedure Register_Callback (C : in Callback_Access); procedure Fire_All; end Callbacks; -- -- callbacks.adb -- with System.Address_To_Access_Conversions; package body Callbacks is subtype Void_Ptr is System.Address; package Conversions is new System.Address_To_Access_Conversions (Object => Callback'Class); -- helper translator, -- will be directly called by the C++ wrapper: procedure Callback_Translator (Obj : in Void_Ptr); pragma Convention (C, Callback_Translator); procedure Callback_Translator (Obj : in Void_Ptr) is Callback_Handler : Callback_Access := Callback_Access (Conversions.To_Pointer (Obj)); begin -- actual dispatching call to the Ada implementation: Callback_Handler.all.Call; end Callback_Translator; procedure Register_Callback (C : in Callback_Access) is procedure Wrapped_Register_Callback (Fun : in Void_Ptr; Obj : in Void_Ptr); pragma Import (C, Wrapped_Register_Callback, "wrapped_registerCallback"); begin Wrapped_Register_Callback (Callback_Translator'Address, Conversions.To_Address (Conversions.Object_Pointer (C))); end Register_Callback; procedure Fire_All is procedure Wrapped_Fire_All; pragma Import (C, Wrapped_Fire_All, "wrapped_fireAll"); begin Wrapped_Fire_All; end Fire_All; end Callbacks;
As can be seen above, the package specification is already "digestible" as a high-level component that can be directly used in the same way as the base component in C++. The actual translation work is being done in the package body, where the object-oriented callbacks are decomposed into two access values - the who, which directly corresponds to the handler object, and the what, or action component, which is a trampoline subprogram that takes the object address from the C++ layer and uses it for a final dispatching call to the Ada callback handler.
The example Ada program that uses the whole mechanism can look like this:
-- -- example.adb -- with Ada.Text_IO; with Callbacks; procedure Example is type Some_Callback is new Callbacks.Callback with null record; overriding procedure Call (Self : in Some_Callback); overriding procedure Call (Self : in Some_Callback) is begin Ada.Text_IO.Put_Line ("Ada: Some Callback called"); end Call; type Other_Callback is new Callbacks.Callback with null record; overriding procedure Call (Self : in Other_Callback); overriding procedure Call (Self : in Other_Callback) is begin Ada.Text_IO.Put_Line ("Ada: Other Callback called"); end Call; SC : aliased Some_Callback; OC : aliased Other_Callback; begin Ada.Text_IO.Put_Line ("Ada: registering callbacks"); Callbacks.Register_Callback (SC'Unchecked_Access); Callbacks.Register_Callback (OC'Unchecked_Access); Ada.Text_IO.Put_Line ("Ada: fire all!"); Callbacks.Fire_All; end Example;
For completeness, the following compiler invocations can be used to build the final executable:
$ g++ -c base.cpp $ g++ -c wrapper.cpp $ gnatmake example -largs wrapper.o base.o -lstdc++
An important property of this solution is that it is not intrusive with relation to the base component, which here was written in C++. This reflects a typical scenario where a C++ library is wrapped by a binding layer in Ada. The technique presented here allows to implement an efficient binding without introducing any reverse dependency between C++ and Ada and without modifying the original base component in any way, at the same time preserving the object-oriented nature of the notification mechanism.
Another advantage of this technique is that each layer can be replaced with different implementation - in particular, the low-level base component can be rewritten in Ada with no impact on the user code, as the Ada specification already has the appropriate high-level structure. Other combination of languages and their bindings are also possible with little on no impact on the existing components.
It can be interesting to note that in this implementation of object-oriented (polymorphic) callbacks the two low-level callback constituents - that is, the two pointers that are passed between layers written in two languages - have different usage paths.
The object pointer, which comes from the access value to the Ada callback handler instance, is created at the Ada level, passed down to C++ for storage only and then - when the notification is performed - passed up to Ada again where the pointer is converted back to access value. In other words, the access value is obtained and used within the context of the same programming language and the other language is given that value only for temporary storage.
Contrary to this, the action pointer, which for the binding purpose is obtained from the trampoline procedure in the Ada translation layer, has a somewhat different usage path: it is obtained at the Ada level, passed down to C++, reconstructed as a function pointer and used right there to invoke the trampoline procedure in Ada. That is, the access value is obtained in the context of one programming language but actually used in the context of another. The conversion that is employed for this value at the lower level can be considered to be a weak point of this solution and a potential portability issue - but the truth is that in order to effectively bind two programming languages it is not possible to rely on language standards in isolation and a pair of "friendly" compilers is needed anyway; such a pair can make up for missing standard provisions and that is particularly true for GNAT and g++, which happen to be part of the same compiler toolchain.
The multi-layer architecture of this solution can raise performance-related questions - what is the cost of such dual translation?
Even though no strict measurements have been done to answer such questions in the quantitative way, it is safe to assume that the solution presented here is very efficient. The reason for this is that both translation layers perform only type conversions and are very cheap in terms of object code. In relation to pure-C++ implementation the Ada/C++ binding solution adds an overhead of a single subprogram call and a single dispatching operation call.
This article is related to the industrial presentation that was given at the 15th Reliable Software Conference in Valencia (Spain), 2010.
The video recording of this presentation is available on YouTube.