DSL function objects (C++)

In this blog post I’m going to describe metaprogramming technique that arose when I was working on my WIP library. I have not seen it before so I coined the term DSL (Domain Spesific Language) function object.

DSL function object is function object which is created using some sort of DSL. UDLs (User-Defined Literal) can return DSL function object which has variadic tempalated arguments which are constrainted using a concept which models which combination of arguments is accepted based on the DSL in the UDL string.

Due to usage of template constraints DSL function objects require C++20. I have a feeling that all this could be emulated using some dark sorcery of earlier standards but I have not thought of it enough.

For example we define simple language which just controls which argument types are accepted. Rules for this langauge:

  • only allowed characters ‘A’, ‘B’ and ‘C’.

  • parameter to the DSL function object can only be of type A, B or C (defined in code)

  • parameters have to be given in order defined by the given characters

Then the DSL function object will print values inside these parameters with name provided by std::type_info::name(). UDL for this DSL function object is ""_p. Example usage "ABBCCC"_p(A{1}, B{2}, B{3}, C{4}, C{5}, C{6}); which would give following output on gcc 10.1 (interestingly gcc adds ‘1’ to the names):

1A is 0
1B is 2
1B is 3
1C is 4
1C is 5
1C is 6

One could imagine doing anything you can with these arguments and the logic why some combination of argument could be as complex as one desires.

Below is the code implementing DSL function object described above. It compiles with gcc 10.1 and clang 12.0.0 or newer (except clang 12.0.1) in C++20 mode. For some reason msvc does not compile on any version. Here is the implementation in Godbold.

#include <concepts>
#include <type_traits>
#include <utility>
#include <tuple>
#include <typeinfo>
#include <array>
#include <ranges>
#include <iostream>
#include <string>
#include <stdexcept>
#include <algorithm>
#include <string_view>

// In a other applications these could be any other types.
struct A { int val; };
struct B { int val; };
struct C { int val; };

// Used to denote the set of possible arguments.
template <typename T>
concept argument_set = std::same_as<T, A> or std::same_as<T, B> or std::same_as<T, C>;


// This could be any structural type which can be constructed from the DSL string
struct fixed_string {
    static constexpr std::size_t max_length   = 100;
    std::array<char, max_length + 1> data_ = {}; // Allways contains null at the end

    [[nodiscard]] explicit constexpr fixed_string(const std::string_view input) {
        if (input.size() > max_length)
            throw std::logic_error("fixed_string max capacity exceeded!");

        std::ranges::copy(input, data_.data());
    }

    template<std::size_t N>
        requires(N <= max_length)
    [[nodiscard]] constexpr fixed_string(const char (&input)[N])
        : fixed_string(static_cast<std::string_view>(input)) {}

    [[nodiscard]] constexpr std::string_view sv() const {
        return std::string_view(data_.begin());
    }
};

// Logic to check if type and argument character match.
template <argument_set T>
consteval bool type_and_char_match(const char c) {
    if (std::same_as<T, A>)
        return c == 'A';
    else if (std::same_as<T, B>)
        return c == 'B';
    else // Has to be of type C
        return c == 'C';
}

template<fixed_string S, typename... P>
consteval bool params_are_correct() {
    // If length of string and amount of arguments is different we know it can not be correct.
    if (S.sv().size() != sizeof...(P)) return false;

    // Use immediately invoked lambda with index_sequence to get handle to type I := std::size_t...
    // which then can be folded together with A to check if all parameters types match the character.
    return []<std::size_t... I>(std::index_sequence<I...>) {
        return (type_and_char_match<P>(S.sv()[I]) and ...);
    }(std::make_index_sequence<sizeof...(P)>{});
}

// DSL function object
template <fixed_string S>
struct custom_args {
    template <argument_set... P>
    requires (params_are_correct<S, P...>())
    void operator()(P... params)
    {
       ((std::cout << typeid(params).name() << " is " << params.val << "\n"), ... );
    }
};

template<fixed_string expr>
constexpr auto operator""_p() -> custom_args<expr> {
    return { };
};

int main() {
    std::cout << "ABC:\n";
    "ABC"_p(A{1}, B{2}, C{3});
    std::cout << "ABBCCC:\n";
    "ABBCCC"_p(A{1}, B{2}, B{3}, C{4}, C{5}, C{6});

    // These do not compile:
    // ""_p(A{1}, B{2}, C{3});
    // "CBA"_p(A{1}, B{2}, C{3});
    // "BA"_p(A{1}, B{2}, C{3});
    //
    // "ABC"_p(B{2}, C{3});
    // "ABC"_p();
}