CADDi Tech Blog

モノづくり産業のポテンシャルを開放するCADDiのTech Blogです。

Row Polymorphism in C++20

Summary

This post is my hobby and has nothing to do with work.

I have wanted Extensible Records (a library in Haskell) for a long time. The time has finally come. The language features we need to implement it are there in C++20!

Therefore, this post will show you how to emulate row polymorphism in C++20. The latest, complete code can be found in this repository.

Row Polymorphism

Row polymorphism is a kind of polymorphism that allows one to write programs that are polymorphic on record field types (also known as rows, hence row polymorphism).

Here is a TypeScript example:

type foo = { first: string, last: string };

const o = { first: "Foo", last: "Oof", age: 30 };
const p = { first: "Bar", last: "Rab", age: 45 };
const q = { first: "Baz", last: "Zab", gender: "m" };

const main = <T extends foo>(o: T) => (p: T) => o.first + o.last

main(o) (p); // type checks
main(o) (q); // type error

Mitama.Data.Extensible.Record

In TypeScript, it is implemented as a language feature, but in C++20, there is no such feature, so we need to emulate it somehow. The reference is the famous Haskell library extensible which emulates the same feature.

In the end I succeeded in making a library which allows the following syntax.

import Mitama.Data.Extensible.Record;
#include <iostream>
#include <format>

using namespace mitama::literals;
using namespace std::literals;

void print(mitama::has<"name"_, "age"_> auto person) {
    std::cout << std::format("name = {}, age = {}\n", person["name"_], person["age"_]);
}

int main() {
    using mitama::as;
    // declare record type
    using Person = mitama::record
                   < mitama::named<"name"_, std::string>
                   , mitama::named<"age"_,  int>
                   >;

    // make record
    Person john = Person{
        "name"_v = "John"s,
        "age"_v  = 42,
    };

    // access to rows
    john["name"_]; // "John"
    john["age"_];  // 42

    print(john); // OK

    auto tom = mitama::empty
             += as<"name"_>("Tom"s)
             ;

    print(tom); // ERROR: constraints not satisfied
}

Guide-level explanation

mitama::named

The syntax "name" and "age" is a UDL (User-Defined Literal). These literals create a type mitama::static_string which is a structual type (non-type template enabled class).

Thus, mitama::named<"age"_, int> is a wrapped type of int named with mitama::static_string.

You can construct mitama::named<_, T> by applying operator%(static_string, T).

mitama::named age = "age"_v = 42;

mitama::named has some interfaces like std::optional.

mitama::named age = "age"_v = 42;
age.value(); // 42

mitama::named name = "name"_v = "Mitama"s;
name->length(); // calls std::string::length and returns 6

mitama::record

mitama::record<Rows... > is constrained to only take mitama::named in Rows... and strings in mitama::named must be distinct.

When initializing a particular record type, the order of the initializers is free, because the Row strings are distinct.

using Person = mitama::record
               < mitama::named<"name"_, std::string>
               , mitama::named<"age"_,  int>
               >;
// OK
Person john = Person {
    "name"_v = "John"s,
    "age"_v = 42,
};
// Also OK
Person tom = Person {
    "age"_v  = 42,
    "name"_v = "Tom"s,
};

We can make mitama::record with CTAD (Class template argument deduction). In this case, Rows... of the record is inferred in the order of the initializers, so different record types are inferred depending on the order of the initializers.

// john: record< named<"name"_, std::string>, named<"age"_, int> >
auto john = mitama::record {
    "name"_v = "John"s,
    "age"_v  = 42,
};

// tom: record< named<"age"_, int>, named<"name"_, std::string> >
auto tom = mitama::record {
    "age"_v  = 42,
    "name"_v = "Tom"s,
};

Use mitama::shrink to convert between records that have the same rows but in a different order.

// decltype(john) _ = tom; // ERROR
decltype(john) _ = mitama::shrink(tom); // OK

In fact, a = shrink(b); converts b: B to a: A where A ⊆ B.

using Person = mitama::record
               < mitama::named<"name"_, std::string>
               , mitama::named<"age"_,  int>
               >;

auto tom = mitama::record {
    "name"_v   = "Tom"s,
    "age"_v    = 42,
    "gender"_v = "m",   // an extra row
};

Person tom2 = mitama::shrink(tom); // OK

Reference-level explanation

How to specify string literals as a non-type template parameter

Basic idea

In order to specify string literals as a non-type template parameter, we first create a structural type class that holds const CharT [N]. CharT is a structural, and an array of a structural type is also structural. And the class such that all base classes and non-static data members are public and non-mutable and the types of all bases classes and non-static data members are structural types or (possibly multi-dimensional) array thereof is structural. Thus, fixed_string below is a structural type.

template<std::size_t N, class CharT>
struct fixed_string {
    static constexpr std::size_t size = N;
    using char_type = CharT;

    constexpr fixed_string(CharT const (&s)[N])
      : fixed_string(s, std::make_index_sequence<N>{}) {}
    template<std::size_t ...Indices>
    constexpr fixed_string(CharT const (&s)[N], std::index_sequence<Indices...>)
      : s{ s[Indices]... } {}

    CharT const s[N];
};

We can use fixed_string as a non-type template parameter, and CTAD will automatically infer CharT and N from string literals.

template <fixed_string S>
struct static_string {
    using char_type = typename decltype(S)::char_type;
    static constexpr auto size = decltype(S)::size;
};

int main() {
    using ss1 = static_string<"test">;
    static_assert(std::same_as<char, typename ss1::char_type>);

    using ss2 = static_string<u"test">;
    static_assert(std::same_as<char16_t, typename ss2::char_type>);

    using ss3 = static_string<U"test">;
    static_assert(std::same_as<char32_t, typename ss3::char_type>);
}

Complete idea

In order to be able to handle std::string_view at compile time, static_string should have static constexpr std::string_view.

template<fixed_string S>
struct static_string {
    using char_type = typename decltype(S)::char_type;
    static constexpr std::basic_string_view<char_type> const value = { S.s, decltype(S)::size };
};

Furthermore, creating a static_string with UDL gives an appearance like "name". The operator ""() allows us to pass string literals directly to the template parameter, so that we can construct a static_string using the fixed_string deduced by CATD.

namespace mitama:: inline literals:: inline static_string_literals{
  template <fixed_string S>
  inline constexpr auto operator ""_() noexcept {
    return static_string<S>{};
  }
}

How to access to rows in a record

mitama::named has a protected member operator[] for record.

template <static_string Tag, class T>
class named {
public:
    static constexpr std::string_view str = decltype(Tag)::value;
    // ...
protected:
    template <auto S> requires (static_string<S>::value == str)
    constexpr delctype(auto) operator[](static_string<S>) const noexcept {
        return storage::deref();
    }
};

Rows... in mitama::record<Rows... > should all be mitama::named. mitama::record inherits Rows... and make operator[] visible by using declaration.

template <named_any ...Rows>
class record
    : protected Rows...
{
public:
    // ...

    using Rows::operator[]...;
};

This will enable to access rows through operator[] by overload resolution.

How to check the equivalence of rows in two records

In C++20, - lots of features can be used at compile time, - lots of classes and functions can be used at compile time, - template syntax for generic lambdas is available and - consteval is available.

However, due to lot of compiler bugs, some lack of implementation and some Core Issues, it has become difficult to deliver accurate and elegant code to you. So I leave you with the challenge of the modern metaprogramming techniques in C++20.

The code, with workarounds everywhere, can be found here.

Appendix A: development environment

Visual Studio 2022 Version 17.0.0 Preview 5.0

References