arximboldi / immer

Postmodern immutable and persistent data structures for C++ — value semantics at scale
https://sinusoid.es/immer
Boost Software License 1.0
2.49k stars 179 forks source link

Suggestion: pseudo-dynamic immutable records with lenses #72

Closed anton-pt closed 2 years ago

anton-pt commented 5 years ago

I'm a bit new to C++ and most of my experience is in FP languages, so please shoot me down if this is a terrible suggestion. However, here goes...

I recently watched the CppCon 2017 talk and greatly enjoyed it. One of the questions at the end got me thinking: can this library help with creating user-defined immutable data types? Furthermore, can we get some of the benefits of Clojure's dynamism by having the underlying data representation be arbitrarily extensible? And can we make it ergonomic by borrowing some more concepts from FP, namely lenses?

Here's the idea. Firstly, have the "record" type backed by a const immer::map<std::string, std::shared_ptr<void>>. Define suitable constructors which take the possible combinations of arguments and initialise the map. Define helper functions which write to or read from the map in a strongly-typed manner (applying the necessary casts to and from shared_ptr). Of course, writing is an immutable operation, so you get a new value as a result, with the benefits of structural sharing. Define a lens type so that for every field with a getter and setter, you can have a lens. The lenses can be compositional. This way, you could drill into arbitrarily deep hierarchies of these record types and manipulate the field you are interested in.

Here's an example of the basic underlying implementation which I have in mind:

class person {
public:
    person(
            const std::shared_ptr<std::string>& name,
            const std::shared_ptr<uint8_t>& age) :
            _data(immer::map<std::string, std::shared_ptr<void>>()
                          .set(_name, std::static_pointer_cast<void>(name))
                          .set(_age, std::static_pointer_cast<void>(age)))
    { }

    std::shared_ptr<const std::string> get_name() const
    {
        return std::static_pointer_cast<const std::string>(_data[_name]);
    }

    const person set_name(const std::shared_ptr<std::string>& name) const
    {
        return person(_data.set(_name, std::static_pointer_cast<void>(name)));
    }

    std::shared_ptr<const uint8_t> get_age() const
    {
        return std::static_pointer_cast<const uint8_t>(_data[_age]);
    }

    const person set_age(const std::shared_ptr<uint8_t>& age) const
    {
        return person(_data.set(_age, std::static_pointer_cast<void>(age)));
    }

private:
    explicit person(immer::map<std::string, std::shared_ptr<void>> data) : _data(std::move(data)) { }

    const immer::map<std::string, std::shared_ptr<void>> _data;

    static constexpr char _name[] { "name" };
    static constexpr char _age[] { "age" };
};

constexpr char person::_name[];
constexpr char person::_age[];

Then, in use:

auto john = person(
        std::make_shared<std::string>("John Smith"),
        std::make_shared<uint8_t>(42));

auto junior = john
        .set_name(std::make_shared<std::string>("Johnny Junior"))
        .set_age(std::make_shared<uint8_t>(12));

std::cout << *john.get_name() << ", age: " << +*john.get_age() << std::endl;
std::cout << *junior.get_name() << ", age: " << +*junior.get_age() << std::endl;

I also wonder if, using a clever enough template, the record definition could be made to look something like:

class person : public record<
        field<std::string, "name">,
        field<uint8_t, "age">> { };

With the combination of string interning for the field names, I think this could be made performant (certainly on par with Clojure).

What do you think?

anton-pt commented 5 years ago

I extended my code with a simple demonstration of lenses. Constructive criticism would be most welcome.

Here are the supporting classes:

#include <iostream>
#include <memory>
#include <functional>
#include "immer/map.hpp"

template <typename TField, const char* name>
class field_setter {
public:
    explicit field_setter(std::shared_ptr<TField> value) : _value(std::move(value)) { }

    immer::map<std::string, std::shared_ptr<void>> apply(immer::map<std::string, std::shared_ptr<void>> data) const {
        return data.set(name, std::static_pointer_cast<void>(_value));
    }

private:
    const std::shared_ptr<TField> _value;
};

template<typename TRecord, typename TField>
class lens {
public:
    lens(
            std::function<std::shared_ptr<const TField>(TRecord)> get,
            std::function<TRecord(TRecord, const std::shared_ptr<TField>&)> set)
            : _get(std::move(get)), _set(std::move(set)) { }

    std::shared_ptr<const TField> get(TRecord record) const  { return _get(record); }
    TRecord set(TRecord record, const std::shared_ptr<TField>& value) const { return _set(record, value); }

private:
    std::function<std::shared_ptr<const TField>(TRecord)> _get;
    std::function<TRecord(TRecord, std::shared_ptr<TField>)> _set;
};

template <typename TA, typename TB, typename TC>
lens<TA, TC> operator>>(lens<TA, TB> left, lens<TB, TC> right) {
    return lens<TA, TC>(
            [=](TA record) { return right.get(*left.get(record)); },
            [=](TA record, const std::shared_ptr<TC>& value) {
                return left.set(record, std::make_shared<TB>(right.set(*left.get(record), value)));
            });
}

template <typename TField, const char* name>
immer::map<std::string, std::shared_ptr<void>> operator>>(immer::map<std::string, std::shared_ptr<void>> data, const field_setter<TField, name>& setter) {
    return setter.apply(data);
}

template<typename TRecord, typename TField, const char* name>
class field {
public:
    static std::shared_ptr<const TField> get(const immer::map<std::string, std::shared_ptr<void>>& data) {
        return std::static_pointer_cast<const TField>(data[name]);
    }

    static TRecord set(immer::map<std::string, std::shared_ptr<void>> data, const std::shared_ptr<TField>& value) {
        return TRecord(data.set(name, std::static_pointer_cast<void>(value)));
    }

    static field_setter<TField, name> setter(const std::shared_ptr<TField>& value) {
        return field_setter<TField, name>(value);
    }

    static lens<TRecord, TField> optic() {
        return lens<TRecord, TField>(
                [](TRecord record) { return get(record.raw_data()); },
                [](TRecord record, const std::shared_ptr<TField>& value) { return set(record.raw_data(), value); });
    }
};

class record {
public:
    using data = immer::map<std::string, std::shared_ptr<void>>;
    explicit record(data data) : _data(std::move(data)) { }

    data raw_data() { return _data; }

protected:
    const data _data;
};

And here is some example application code built on top of them:

class contact : public record {
public:
    contact(const std::shared_ptr<std::string>& telephone, const std::shared_ptr<std::string>& email)
        : record(data() >> telephone::setter(telephone) >> email::setter(email)) { }

    explicit contact(data data) : record(std::move(data)) { }

    std::shared_ptr<const std::string> get_telephone() const { return telephone::get(_data); }
    const contact set_telephone(const std::shared_ptr<std::string>& telephone) const { return telephone::set(_data, telephone); }

    std::shared_ptr<const std::string> get_email() const { return email::get(_data); }
    const contact set_email(const std::shared_ptr<std::string>& email) const { return email::set(_data, email); }

    static lens<contact, std::string> telephone_() { return telephone::optic(); }
    static lens<contact, std::string> email_() { return email::optic(); }

private:
    static constexpr char _telephone[] { "telephone" };
    using telephone = field<contact, std::string, _telephone>;

    static constexpr char _email[] { "email" };
    using email = field<contact, std::string, _email>;
};

constexpr char contact::_telephone[];
constexpr char contact::_email[];

class person : public record {
public:
    person(const std::shared_ptr<std::string>& name, const std::shared_ptr<uint8_t>& age, const std::shared_ptr<contact>& contact)
        : record(data() >> name::setter(name) >> age::setter(age) >> contact::setter(contact)) { }

    explicit person(data data) : record(std::move(data)) { }

    std::shared_ptr<const std::string> get_name() const { return name::get(_data); }
    const person set_name(const std::shared_ptr<std::string>& name) const { return name::set(_data, name); }

    std::shared_ptr<const uint8_t> get_age() const { return age::get(_data); }
    const person set_age(const std::shared_ptr<uint8_t>& age) const { return age::set(_data, age); }

    std::shared_ptr<const contact> get_contact() const { return contact::get(_data); }
    const person set_contact(const std::shared_ptr<contact>& contact) const { return contact::set(_data, contact); }

    static lens<person, std::string> name_() { return name::optic(); }
    static lens<person, uint8_t> age_() { return age::optic(); }
    static lens<person, contact> contact_() { return contact::optic(); }

private:
    static constexpr char _name[] { "name" };
    using name = field<person, std::string, _name>;

    static constexpr char _age[] { "age" };
    using age = field<person, uint8_t, _age>;

    static constexpr char _contact[] { "contact" };
    using contact = field<person, contact, _contact>;
};

constexpr char person::_name[];
constexpr char person::_age[];
constexpr char person::_contact[];

int main() {
    auto john = person(
            std::make_shared<std::string>("John Smith"),
            std::make_shared<uint8_t>(42),
            std::make_shared<contact>(
                    std::make_shared<std::string>("12345"),
                    std::make_shared<std::string>("j.smith@email.com")));

    auto junior = john
            .set_name(std::make_shared<std::string>("Johnny Junior"))
            .set_age(std::make_shared<uint8_t>(12));

    auto person_email = person::contact_() >> contact::email_();

    auto junior_new_email = person_email.set(junior, std::make_shared<std::string>("junior@email.com"));

    std::cout << *john.get_name() << ", age: " << +*john.get_age() << ", email: " << *john.get_contact()->get_email() <<  std::endl;
    std::cout << *junior.get_name() << ", age: " << +*junior.get_age() << ", email: " << *junior.get_contact()->get_email() << std::endl;
    std::cout << *junior_new_email.get_name() << ", age: " << +*junior_new_email.get_age() << ", email: " << *junior_new_email.get_contact()->get_email() << std::endl;
    return 0;
}
arximboldi commented 5 years ago

Hi Anton!

I will study your code more carefully later. Just wanted to point out that I am indeed relatively familiar with lenses. I am specially interested in observable mutating lenses associated to stateful sources (like cursors in Om or reactive atoms and trackables in Reagent).

I wrote a library called Funken while working at Ableton tha tries to solve a similar problem in C++ (Funken is part of Atria that was inspired by another Clojure implementation by @brentshields, I can't find it online anymore though.) That might be an interesting place for you to look into. The documentation is scarce but reading the unit tests might help a bit. I am also working on a similar library right now for a client, but I am not sure if we will ever publish it.

Cheers!

JP

anton-pt commented 5 years ago

Thanks for the quick response, and I look forward to your feedback! I'll look into the libraries you mentioned. I've made a few improvements to the code, including hashing the strings at compile time and caching the result, and also a slightly more ergonomic API for construction with rvalue reference arguments, and so forth. The result is here.

anton-pt commented 5 years ago

Just thought I'd mention that I've made quite a lot of updates to the gist. If you think there's a place for something like this in immer, I'd be keen to get it up to the required quality.

arximboldi commented 2 years ago

This is now provided to some degree by Lager.