Search code examples
c++genericstemplate-meta-programmingtype-traitspolicy-based-design

Zero-dependency traits definition


I am experimenting and trying to make a template policy-based meta library. Example case is aggregating 2 classes for a device driver. The classes implement device_logic and connection_logic, they don't need to depend on each other's type:

  • device logic depends only on a communication protocol (messages).
  • connection_logic is just a source of byte arrays and may use different kinds of connections: SerialPort, tcp, udp, custom PCI express device, etc.

The goal is not to force any interfaces or types on them. They must depend purely on the API specification and only provide necessary traits.

The STL approach is to define traits in a header and then use them inside a class. So the traits tags must be defined in a header of a template library.

// device_traits.h

namespace traits
{

   // tags to be defined as io_type
   struct writeable;
   struct readable;
   struct wretableReadable;


   template <typename T>
   constexpr bool is_writeable()
   {
       return std::is_same_v<writeable, typename T::io_type>() ||
              std::is_same_v<wretableReadable, typename T::io_type>();
   }

   // functions for readable and readableWriteable
      
}

template <typename ConnectionLogic,
          typename DeviceLogic>
class aggregate_device
{

static_assert(!traits::readable<DeviceLogic>() ||
              (traits::readable<DeviceLogic>() &&
               traits::readable<ConnectionLogic>()),
               "Device logic is readable so must be ConnectionLogic");

static_assert(!traits::writeable<DeviceLogic>() ||
              (traits::writeable<DeviceLogic>() &&
               traits::writeable<ConnectionLogic>()),
               "Device logic is writeable so must be ConnectionLogic");

};

In this case aggregate_device aggregates connection and device logic. If device logic is readable, the connection logic must provide input. If device logic is writeable, the connection must provide output.

// device_logic.h
#include <device_traits>

class device_logic
{
public:
   using io_type = traits::readableWriteable;
   // ... methdos, etc
};

This version works but introduces a dependency on the template library. Introducing dependency (even a header-only library) is not convenient for a developer and generally not good for a library. Someone might want to use device_logic class in another module or project, but not want to pull a template library it depends on.

Another solution which removes the dependency is not to force a class provider to inject io_type tags to his class but to define them on his own.

// device_traits.h

namespace traits
{

   template<typename, typename = void>
   struct is_writeable : std::false_type{};

   // here we just check if a typename has a type writeable
   template<typename T>
   struct is_writeable<T, std::void_t<typename T::writeable>> : std::true_type{};

   // functions for readable and readableWriteable
      
   // aggregator class
}

// device_logic.h
// don't include nothing


class device_logic
{
   public:

   // define a type 
   struct writeable;
};


/////
#include <device_traits>

static_assert(traits::is_writeable<device_logic>(), "");

Now I use the second approach and it works. The questions are:

  • Is it a legit approach?
  • Wouldn't it be confusing for a class provider?
  • Will it be (at what extent) harder to maintain?
  • What may be the differences in performance for compiling?

Solution

  • Is it a legit approach?
    Wouldn't it be confusing for a class provider?

    standard uses different approaches:

    • presence of type such as transparent comparers which should have a "type" is_transparent (as using is_transparent = void;)

    • specific tags as iterator_tags.

    • or even just duck-typing (no check for template)

    • Or SFINAE on existence of method/properties.

    Those types might be:

    • inside the class (as for is_transparent)
    • or provided as external traits such as std::iterator_traits (which even allows to extract, when possible, inner typedef from the class).

    Notice that only external traits might support built-in types (pointers, int, ...) or external types (3rd library, or standard library for your traits) in a non-intrusive way.

    Will it be (at what extent) harder to maintain?

    There is a trade-of between

    • "physical" dependency, where stuff are, so, more linked, and possibly simpler to stay synchronized, but create a dependency.

    • no "physical" dependency, so potentially harder to stay synchronized.

    What may be the differences in performance for compiling?

    As always, you have to measure.

    For example with build-bench.com.

    To use together, it seems you have to include similar code, but not necessary in same order, so I would bet for similar performance.

    When used independently, you should avoid one extra #include (so depends of it size/number of #include, if pch is used, ...)...