Interfaces, Objects and Wrappers

One of the primary goals of the openDAQ™ SDK is its cross-platform and cross-language operability. Its architecture was designed with the said goal in mind, while still striving to keep its API simple and easy to use. As a solution, openDAQ™ creates C-compatible interfaces that can be passed across shared libraries. It uses the fact that the C ABI is well-defined and standardized to create a framework of interfaces between the library and application that closely follows that of the COM architecture.

In this article, we provide insight into the lower-level working of the interfaces, mostly used in the openDAQ™ internals, and describe how the shortcomings of these interfaces are addressed through pointer wrappers. Users of openDAQ™ will in most cases only interact with the pointer wrapper objects, however, a general understanding of interface programming in openDAQ™ is advisable for integrators and openDAQ™ developers.

Interfaces

The aforementioned architecture is implemented in C++ through the use of abstract structs with pure virtual functions. Such structs share the C interface ability to be passed across shared library boundaries by all compilers on any platform in the same manner. To be more precise, objects can be passed as pointers to interfaces, allowing for sharing of OOP structures and modularized components across multiple shared libraries.

As with COM interfaces, all openDAQ™ interfaces implement the IUnknown interface, which provides functions for reference counting and type casting. openDAQ™ interfaces also implement the IBaseObject interface, providing functions for tasks such as object hashing, comparison, and cleanup.

struct IUnknown
{
    virtual ErrCode INTERFACE_FUNC queryInterface(const IntfID& intfID, void** obj) = 0;
    virtual int INTERFACE_FUNC addRef() = 0;
    virtual int INTERFACE_FUNC releaseRef() = 0;
};
struct IBaseObject : public IUnknown
{
    virtual ErrCode INTERFACE_FUNC borrowInterface(const IntfID& intfID, void** obj) const = 0;
    virtual ErrCode INTERFACE_FUNC dispose() = 0;
    virtual ErrCode INTERFACE_FUNC getHashCode(SizeT* hashCode) = 0;
    virtual ErrCode INTERFACE_FUNC equals(IBaseObject* other) const = 0;
    virtual ErrCode INTERFACE_FUNC toString(CharPtr* str) = 0;
};

The fundamental mechanism of working with interfaces is expressed through the queryInterface function. Modeled after the namesake COM function, queryInterface allows an object to be queried for a pointer to a different interface with the intfID interface ID. If the queried object supports that interface, queryInterface retrieves a pointer and automatically calls addRef, increasing the object’s reference count.

Briefly mentioned earlier, reference counting in openDAQ™ is done at its lowest level through the addRef and releaseRef calls that increment or decrement an object’s reference count. Following intuition, every object is created with a reference count of one and destroyed once it reaches zero. openDAQ™ interfaces embrace a model where its objects do not increment their reference count when passed as function parameters. They instead "borrow" the reference. borrowInterface provides a function embracing said behavior by returning a pointer to a different interface without incrementing the caller object’s reference count.

openDAQ™ Interface Structure

In the following example, we declare a new interface ICustomInterface with a foo() method. The DECLARE_OPENDAQ_INTERFACE macro defines a struct, named as input in the 1st macro parameter, that inherits the interface in the 2nd parameter. The macro generates an interface ID that is used when querying the interface.

INTERFACE_FUNC evaluates to __stdcall on Windows systems.
// custom_interface.h
#pragma once
#include <coretypes/baseobject.h>

DECLARE_OPENDAQ_INTERFACE(ICustomInterface, IBaseObject)
{
    virtual ErrCode INTERFACE_FUNC foo() = 0;
}

Interface Levels

The openDAQ™ SDK defines three levels of interfaces. Here we provide a very rough overview of the levels:

  • Core types: Basic type interfaces that wrap common programming constructs such as integers (IInteger), lists (IList), dictionaries (IDict), strings (IString)…​

  • Core objects: Interfaces allowing for the creation of property objects (IPropertyObject). Property Objects are containers for a set of properties, with each property having a string key, value, and metadata description of the property (IPropertyInfo).

  • openDAQ™: Interfaces that correspond to data acquisition concepts. Devices (IDevice), signals (ISignal), channels (IChannel), and other similar concepts are featured here. A lot of the concepts (the three mentioned earlier for example) are property objects that inherit the IPropertyObject interface.

openDAQ™ creates interfaces for basic structures such as integers and floating point numbers to allow for them to be contained within openDAQ™ containers such as lists, dictionaries, and property objects.

Objects

Interfaces, however, are only that - an interface into an implementation. An openDAQ™ object class can implement one or more openDAQ™ interfaces, defining their behavior and effects. An interface describes a function in general, but each implementation defines what exactly happens during the function call. For example, when supporting measurement devices in openDAQ™, each will provide its own implementation of the IDevice interface, which will differ depending on the device’s behavior and requirements.

The below code snippet features an example implementation of the above ICustomInterface interface.

// custom_interface_impl.h
#pragma once
#include <opendaq/custom_interface.h>
#include <iostream>

class CustomInterfaceImpl : public ImplementationOf<ICustomInterface>
{
    CustomInterfaceImpl(Int num)
    {
        for (int i = 0; i < num; ++i)
            std::cout << "foo" << std::endl;
    }

    ErrCode INTERFACE_FUNC foo() override
    {
        std::cout << "foo" << std::endl;
        return OPENDAQ_SUCCESS;
    }
}

To bind interfaces and implementations, openDAQ™ provides factory functions used to create an implementation class object and return it as a pointer to one of its implemented interfaces. Internally, factories create an extern "C" factory function that calls the class constructor and creates a new openDAQ™ object. These factories can, in the cross-platform spirit of openDAQ™, be invoked from any application that uses the openDAQ™ shared libraries.

// custom_interface.h
OPENDAQ_DECLARE_CLASS_FACTORY(LIBRARY_FACTORY, CustomInterface, Int, num)

// custom_interface_impl.cpp
OPENDAQ_DEFINE_CLASS_FACTORY(LIBRARY_FACTORY, CustomInterface, Int, num)

The above two macros define the factories, binding ICustomInterface to CustomInterfaceImpl, specifying that the constructor has an integer parameter num.

The above macros expand to the following function definitions/declarations:

// custom_interface.h
extern "C" daq::ErrCode __declspec(dllexport) createCustomInterface(ICustomInterface** obj, Int num);

inline ICustomInterface* CustomInterface_Create(Int num)
{
    ICustomInterface* obj;
    daq::ErrCode res = createCustomInterface(&obj, num);
    daq::checkErrorInfo(res);
    return obj;
}

// custom_interface_impl.cpp
extern "C" daq::ErrCode __declspec(dllexport) createCustomInterface(ICustomInterface** objTmp, Int num)
{
    return daq::createObject<ICustomInterface, CustomInterfaceImpl, Int>(objTmp, num);
}

Pointer Wrappers

These building blocks available in openDAQ™ enable users to construct their data acquisition applications and shared libraries, accessible from various platforms and programming languages. However, generality and multi-platform frameworks tend to come with a slew of disadvantages. Manually handling reference counting and needing to cast between different interfaces to perform simple type conversions and value comparisons is very tedious. It requires developers to write significant amounts of boilerplate code that can lead to more errors and buggy code. To combat this, openDAQ™ introduces openDAQ™ pointer wrappers.

openDAQ™ pointer wrappers automatically handle several basic mechanisms and abstract away many complexities of working with interfaces. They wrap an interface, allowing access to all interface functions, but provide features available in modern programming languages.

Some of the main features are:

  • Reference counting: Reference count is managed by smart pointers, incrementing it on creation from an lvalue and decrementing it once the pointer goes out of scope.

  • Boxing & unboxing: Conversion from value types (int, double, bool,…​) to openDAQ™ objects.

  • Conversion: Conversion between objects of base value types (IInteger, IBool, IFloat,…​).

  • Comparison: Comparison between smart pointers and openDAQ™ objects and value types.

  • Casting: Helper methods for casting between interfaces.

In the 1st example below, we make use of pure interface programming, while the 2nd example features the use of openDAQ™ pointer wrappers.

Comparing and converting two numbers using raw interfaces
using namespace daq;

{
    // Create Integer object, with a pointer to its IInteger interface
    IInteger* intObj = Integer_Create(10);

    // Query intObj for the IConvertible interface
    IConvertible* convertible;
    intObj->queryInterface(IConvertible::Id, reinterpret_cast<void**>(&convertible));

    // Convert the integer object to a Float value type
    Float floatVal;
    convertible->toFloat(&floatVal);

    // Create a Float object, with a pointer to its IFloat interface
    IFloat* floatObj = Float_Create(10.0);

    // Query intObj for the IComparable interface and compare it to floatObj
    IComparable* comparable;
    intObj->queryInterface(IComparable::Id, reinterpret_cast<void**>(&comparable));
    if (comparable->compareTo(floatObj) == OPENDAQ_EQUAL)
        std::cout << "equal" << std::endl;

    // Query intObj for the INumber interface which it implements
    INumber* number;
    intObj->queryInterface(INumber::Id, reinterpret_cast<void**>(&number));

    // Release the references from the openDAQ objects to avoid memory leaks
    floatObj->releaseRef();
    intObj->releaseRef();
    convertible->releaseRef();
    comparable->releaseRef();
    number->releaseRef();
}
Comparing and converting two numbers using smart-pointer wrappers
using namespace daq;

{
    // Boxing & Unboxing
    IntegerPtr intObj = 10;
    FloatPtr floatObj = 10.0;

    // Conversion
    Float floatVal = intObj;

    // Comparison
    if (floatObj == intObj)
        std::cout << "equal" << std::endl;

    // Casting
    NumberPtr number = intObj;

    // ReleaseRef is called automatically once the objects go out of scope
}