2
\$\begingroup\$

recently I implemented a little helper class, which can be primarily utilized on perfect forwarding constructors.

Introduction

As a slight example, let me pull in two of the std::pair constructors:

template< class U1, class U2 >
constexpr pair( U1&& x, U2&& y );  // (1)

template< class... Args1, class... Args2 >
constexpr pair( std::piecewise_construct_t,
                std::tuple<Args1...> first_args,
                std::tuple<Args2...> second_args );  // (2)

see: cppreference.com

You have the choice to either construct your first/second manually in forehand, or you have to provide the std::piecewise_construct tag object and must wrap the arguments in a tuple.

Given the immobile neither copy- nor movable type:

template <class... Ts>
class immobile
{
public:
    ~immobile() = default;

    immobile(const immobile&) = delete;
    immobile& operator =(const immobile&) = delete;
    immobile(immobile&&) = delete;
    immobile& operator =(immobile&&) = delete;

    template <class... Args>
        requires std::constructible_from<std::tuple<Ts...>, Args...>
    explicit immobile(Args&&... args)
        : value{std::forward<Args>(args)...}
    {
    }

    std::tuple<Ts...> value;
};

We can not use the (1) of the pair constructor - we have to use the second one:

std::pair<immobile<std::string, int>, int> p1(immobile<std::string, int>{"Hello, World!", 1337}, 42); // error
std::pair<immobile<std::string, int>, int> p2{std::piecewise_construct, std::tuple{"Hello, World!", 1337}, std::tuple{42}};

This is noisy and we have to explicitly wrap our arguments as tuples and most importantly: There is absolutely no benefit for the int member to provide the argument as a tuple.

Solution

I would like to have the choice to individually provide the arguments of the pair elements and get rid of that std::piecewise_construct.

I came up with that little helper class:

namespace sl
{
    template <class Type, class... Args>
        requires std::same_as<Type, std::remove_cvref_t<Type>>
            && std::constructible_from<Type, Args...>
    struct in_place_constructor
    {
    public:
        in_place_constructor(const in_place_constructor&) = delete;
        in_place_constructor& operator =(const in_place_constructor&) = delete;
        in_place_constructor(in_place_constructor&&) = delete;
        in_place_constructor& operator =(in_place_constructor&&) = delete;

        ~in_place_constructor() = default;

        [[nodiscard]]
        in_place_constructor() = default;

        [[nodiscard]]
        constexpr operator Type() && noexcept(std::is_nothrow_constructible_v<Type, Args...>)
        {
            return std::make_from_tuple<Type>(std::move(m_Args));
        }

        template <class T, class... Ts>
        friend constexpr auto in_place(Ts&&... args) noexcept;

    private:
        std::tuple<Args...> m_Args;

        [[nodiscard]]
        explicit constexpr in_place_constructor(std::tuple<Args...>&& args) noexcept
            : m_Args{std::move(args)}
        {
        }
    };

    template <class Type, class... Args>
    [[nodiscard]]
    constexpr auto in_place(Args&&... args) noexcept
    {
        return in_place_constructor<Type, Args&&...>{std::forward_as_tuple(std::forward<Args>(args)...)};
    }
}

which let me rewrite the initialization from above as:

std::pair<immobile<std::string, int>, int> p3{sl::in_place<immobile<std::string, int>>("Hello, World!", 1337), 42};

But wait, there is more!

Explicitly expecting in_place_constructor as ctor argument

Custom classes can utilize that helper type for themselves and expecting that as one or multiple constructor arguments. For example:

template <class T1, class T2, class T3>
class MultiInPlaceExpecting
{
public:
    template <class... T1s, class... T2s, class... T3s>
    explicit MultiInPlaceExpecting(
        sl::in_place_constructor<T1, T1s...>&& args1,
        sl::in_place_constructor<T2, T2s...>&& args2,
        sl::in_place_constructor<T3, T3s...>&& args3)
        : value1{std::move(args1)},
        value2{std::move(args2)},
        value3{std::move(args3)}
    {
    }

    T1 value1;
    T2 value2;
    T3 value3;
};

const MultiInPlaceExpecting<std::optional<int>, std::string, int> obj{
    sl::in_place<std::optional<int>>(1337),
    {}, // default ctor
    {}  // default ctor
};

// CTAD!
const MultiInPlaceExpecting obj{
    sl::in_place<std::optional<int>>(1337),
    sl::in_place<std::string>("Hello, World!"),
    sl::in_place<int>(42)};

Questions

Even if I'm quite hyped about my solution, I'm a bit afraid, that I missed something important. I explicitly deleted the copy and move ctors/assignments, enable the converting operator only for rvalue-refs and generally do not see much room, where it could go heavily wrong.

The second question is: Is that the best I can do? Or is there anything I can improve?

If you want to play around, I setup a godbolt project including some test cases: https://godbolt.org/

Thanks for your time,

greetings Dominic

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Making better use of existing functionality

You can in fact use the first form of std::pair's constructor to create a pair where one or both members are immobile. You just have to pass it something that is not immobile that can be used to construct the immobile member(s), and specify std::pair's template parameters explicitly:

std::pair<immobile<std::string, int>, int> p1{std::tuple<std::string, int>{"Hello, World!", 1337}, 42};

This is shorter than your declaration of p3.

Your objection against the second form of std::pair's constructor is that it is too noisy. But you are merely replacing std::piecewise_construct with sl::in_place</* repeated type name */>, which I think is also noisy, and is not even that much shorter. Consider instead that you can create an abbreviation for std::piecewise_construct:

constexpr auto pwc = std::piecewise_construct;
…
std::pair<immobile<std::string, int>, int> p2{pwc, std::tuple{"Hello, World!", 1337}, std::tuple{42}};

That's just one byte longer than the non-compiling definition of p1!

Avoid having to repeat type names

Your class sl::in_place_constructor needs to know Type at construction time. So sl::in_place() also needs to know Type. But this requires you to repeat the type name of the pair member. It would be nice if this could be avoided. Note that Type is only really necessary when calling the conversion operator. But when a conversion operator is called, the caller already knows what it wants to convert to... so could we let Type be deduced automatically? Let's make the conversion operator a template:

template <class... Args>
struct in_place_constructor
{
    …
    template<typename Type>
    [[nodiscard]]
    constexpr operator Type() && noexcept(std::is_nothrow_constructible_v<Type, Args...>)
    {
        return std::make_from_tuple<Type>(std::move(m_Args));
    }

    template <class... Args>
    friend constexpr auto in_place(Args&&... args);

private:
    …
    [[nodiscard]]
    explicit constexpr in_place_constructor(std::tuple<Args...>&& args)
        : m_Args{std::move(args)}
    {
    }
};

template <class... Args>
[[nodiscard]]
constexpr auto in_place(Args&&... args)
{
    return in_place_constructor<Args&&...>{std::forward_as_tuple(std::forward<Args>(args)...)};
}

Now you can write:

std::pair<immobile<std::string, int>, int> p3{sl::in_place("Hello, World!", 1337), 42};

Incorrect use of noexcept

Be careful with noexcept: only use it when you are absolutely sure that nothing can throw. Moving arguments into a tuple? Depending on the types of the arguments, that can certainly happen. Either make noexcept conditional, or just remove it entirely: consider that none of std::pair and std::tuple's constructors are noexcept either.

\$\endgroup\$
5
  • \$\begingroup\$ Hey, thanks for your reply. Looks very promising and I will have a deeper look tomorrow. But I'm wondering about the noexcept part. As std::forward_as_tuple is defined as noexcept and I'm simply making a tuple of references, that shouldn't ever throw. Even moving around a tuple of references shouldn't ever throw. Do I miss anything here? \$\endgroup\$
    – DNKpp
    Sep 1, 2023 at 21:10
  • \$\begingroup\$ Sure std::forward_as_tuple() itself might not throw, but you are still passing this to the constructor of in_place_constructor, which in turn uses it to construct m_Args, and that is not noexcept. \$\endgroup\$
    – G. Sliepen
    Sep 2, 2023 at 8:54
  • \$\begingroup\$ As the move constructor of std::tuple is defaulted, the noexcept specification is deduced by the compiler. And as I said, I`m moving a std::tuple out of pure references, which will never throw, but actually I do not mind making it conditional here, too. \$\endgroup\$
    – DNKpp
    Sep 2, 2023 at 11:38
  • \$\begingroup\$ That's a good point, but are you sure the move constructor will be invoked? You can still pass it a type that is copyable but not movable, in which case the copy constructor will be used despite the explicit std::move(). \$\endgroup\$
    – G. Sliepen
    Sep 2, 2023 at 13:27
  • \$\begingroup\$ Well, the copy-ctor is defaulted, too. So, the same applies here as well, because copying references will also never throw. But I see your point about the uncertainty and will simply make that conditionally noexcept. Thanks! \$\endgroup\$
    – DNKpp
    Sep 2, 2023 at 13:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.