Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SUGGESTION] Named return values can possibly make out parameters redundant #540

Closed
nmsmith opened this issue Jul 13, 2023 · 22 comments
Closed

Comments

@nmsmith
Copy link

nmsmith commented Jul 13, 2023

Motivation

An out parameter works as follows:

  • The caller passes a pointer to a memory location.
  • The callee must treat the memory location as uninitialized, and must initialize it before the function returns.

This is a very useful pattern, but notably, this is very similar to how return values already work in most ABIs. (In most ABIs, whenever a return type requires more than a few registers, the caller must provide a pointer to the memory location that the return value should be written to.)

Consequently, I propose making a slight tweak to out parameters to unify them with the notion of return values. The end result would be that cpp2 has one fewer concept, without sacrificing expressive power or performance. In fact, it will likely make cpp2 programs more performant, because the proposed solution implies NRVO.

The proposal

In today's design, out parameters look like this:

foo: (out s1: std::string, out s2: std::string) = {
    s1 = "hello";
    s2 = "world";
}

main: () = {
    s1: std::string;
    s2: std::string;
    foo(s1, s2);
}

I am proposing to write them like this instead:

foo: () -> (s1: std::string, s2: std::string) = {
    s1 = "hello";
    s2 = "world";
}

main: () = {
    s1: std::string;
    s2: std::string;
    s1, s2 = foo();
}

The differences are:

  • out parameters are moved to the right side of the ->. In other words, they are treated as part of the return type.
  • The caller uses the syntax s1, s2 = foo(), instead of foo(s1, s2).

But notably, the compilation strategy remains the same:

  • Small return values are returned via registers.
  • Large return values are written directly to a memory location provided by the caller.

Basically, what we end up with is an intuitive syntax for guaranteed NRVO. Accordingly, it should be possible to return immovable types (e.g. std::atomic) via this approach. (Because in the style of C++17, we're not "optimizing away" a move. Instead, we're saying that no moves are required.) Ultimately, this is equivalent to out parameters—all we've done is change the syntax.

The likely benefits of this approach include:

  • Better composability. With return values, it is possible to nest function calls (e.g. g(h(...), j(...))), but with out parameters (where functions return void) it is not.
  • Fewer concepts. We wouldn't need out parameters. Instead, we would just need to allow return values to be given names. (Indeed, cpp2 already has syntax for this.)
  • C++ would finally have the equivalent of "guaranteed NRVO"—a useful performance optimization.
@JohelEGP
Copy link
Contributor

JohelEGP commented Jul 13, 2023

Cpp2 already has what you're suggesting:

So what would be left of your suggestion is: "remove out parameters to reduce concept count".
But I don't know if that's desirable, given that out represents "out parameters" (F.21).
Named (multiple) return values can't replace the "Exception" in F.21,
but out parameters can, so I doubt it's worth removing them.

EDIT: Actually, the above can be done with inout parameters.

@JohelEGP
Copy link
Contributor

Actually, I don't think the current implementation of named return values can guarantee copy elision.
IIRC, it essentially lowers the named return values like out parameters, but on the callee.
So it really does assign to local variables, and doesn't return a prvalue.

Herb has previously stated his intention to support named parameters.
It might make sense to wait until then to be able to support copy elision on named return values.
Although that would still require reworking the current named return values feature.

@nmsmith
Copy link
Author

nmsmith commented Jul 13, 2023

Yeah, the big difference between this proposal and the current semantics for named return values in cpp2 is that in the latter case, the return variables are not interpreted as references to a memory location provided by the caller. This is a missed opportunity, IMO.

But once you make that adjustment, out parameters seemingly become redundant. (Which is a good thing if true, because it would constitute a simplification.)

@hsutter
Copy link
Owner

hsutter commented Jul 13, 2023

Thanks! Note that Cpp2 does have two different features here (both of which Cpp1 also has, but here they're generalized with more language support and the ability to declare intent):

  • out parameters, which can accept an initialized or uninitialized argument (not just an unione). For an uninitialized argument, any function with an out argument acts as another (delegating) constructor. For an initialized argument, it assigns over the existing value.
  • Return values, which always produce a new value. These are always initialized by the callee, and always returned by value (if there are multiple return values they are in a generated struct returned by value).

They are similar but do have different use cases. I agree that more often you would just use return values, but out parameters are also useful and cover cases return values don't.

Doing more for copy elision in the implementation of multiple return values is interesting though, good suggestion.

@nmsmith
Copy link
Author

nmsmith commented Jul 13, 2023

@hsutter Thank you for your reply!

Your comment seems to mostly focus on describing how out parameters and return values work currently, and that description is consistent with my understanding. But you don't seem to have provided any reasoning as to why the two constructs cannot be merged into one.

Do you not believe it is possible to merge the two constructs into one, as I have proposed? It looks very possible to me.

@jcanizales
Copy link

I don't see either what the difference in use cases is.

In the caller I can assign the return value of a function to either an initialized variable or an uninitialized one, same as I can pass either as an out argument.

In the callee, the body has to produce a new value for each out parameter, same as for the returned one.

@SebastianTroy
Copy link

SebastianTroy commented Jul 14, 2023 via email

@nmsmith
Copy link
Author

nmsmith commented Jul 14, 2023

named return values [...] their scope starts and ends within the scope of the function call. Out parameters may be declared in another scope.

I think you're mixing up the notions of variables and memory locations:

  • The scope of both NRVs and out parameters (the variables) is that of the callee's function body.
  • With guaranteed NRVO, the memory locations that NRVs and out parameters refer to are provided by the caller.

So there is no distinction there.

@SebastianTroy
Copy link

SebastianTroy commented Jul 14, 2023 via email

@nmsmith
Copy link
Author

nmsmith commented Jul 14, 2023

I'm not sure exactly what you're asking, but I can give you a blanket answer: a function that declares a named return value will behave exactly how a function that declares an out parameter will behave.

In my original post, I mentioned that what I am proposing is essentially just to adjust the syntax of out parameters such that they look like (and compose like) return values.

So by definition, the two features will behave the same.

@realgdman
Copy link

How about interoperability with c++1? Suppose I need to call some API function with out param.

@nmsmith
Copy link
Author

nmsmith commented Jul 14, 2023

I'm not sure what you mean. C++1 doesn't have out parameters, it just has references. (References can be used to simulate out parameters, but the compiler can't tell the difference.) You can pass a reference to a C++1 function call the same as always.

@SebastianTroy
Copy link

SebastianTroy commented Jul 14, 2023 via email

@nmsmith
Copy link
Author

nmsmith commented Jul 14, 2023

@SebastianTroy Irrespective of whether the function accepts an out parameter or returns a value, you can store the result in a variable declared in an outer scope:

outerScopeString: string; // uninitialised
{
    outerScopeString = stringInitFunc(); // via named return value
}

With NRVO, this doesn't require any copying or moving. So it behaves exactly the same as an out parameter.

@JohelEGP
Copy link
Contributor

It seems like #123 touches upon this, too:

Out and inout (not referring to cppfront) parameters were born out of legacy requirements as far as I can tell. The problems out parameters are a solution for are much better solved with new features; structured bindings for multiple returns (and of course cppfront's solution for multiple returns) and return value optimization.

@hsutter
Copy link
Owner

hsutter commented Jul 14, 2023

Thanks. I'm thinking about this, it's good feedback and I'm happy to reduce concept count where possible.

Disclaimer: The following is "thinking out loud"... I could be making a simple thinko here.


Given two functions:

auto cpp1_func() -> widget { /*...*/ }

cpp2_func: () -> widget = { /*...*/ }

All these call sites work today, and I assume we'd want them to continue working:

x: widget;
x = cpp1_func();

y: widget;
y = cpp2_func();

x = cpp2_func();
y = cpp1_func();

I think the suggestion is to change the Cpp1 code gen for cpp2_func to emit Cpp1 auto cpp2_func( widget& ) (or similarly widget*, same idea). Then when calling it, we would have to transform the call sites to make the Cpp1 code gen for the calls be cpp2_func( y ) and cpp2_func( x ), but not make that transformation for the call sites to cpp1_func`... is that right?

Currently, name lookup and overload resolution are done in the Cpp1 compiler though, so we can't tell those call sites apart. A future smarter Cpp2 compiler could still do that transformation as a calling convention detail.

FWIW, as a thought experiment, what if there were no return values at all and only out parameters were allowed, either as they are currently implemented or possibly with an updated implementation -- what would/wouldn't work in the use cases you have in mind?

@jcanizales
Copy link

jcanizales commented Jul 14, 2023

what would/wouldn't work in the use cases you have in mind?

This one's easy. You can easily/naturally chain function calls when f(x) represents the output values (g(f(x))), but not when they're written result: T; f(x, out result)

@nmsmith
Copy link
Author

nmsmith commented Jul 15, 2023

@hsutter Yes, I think the code transformation would work roughly as you describe. But you wouldn't need to apply it to every cpp2 function definition and call. You'd only need to apply it to definitions that use named return values:

cpp2_func: () -> (w: widget) = {...}

And you're right: this implies that the cpp2 compiler needs to be able to tell which calls are to NRV functions.

That said, GCC and Clang have recently implemented guaranteed NRVO (following P2025), so if you're compiling your C++ code using those compilers, you can do a much simpler code transformation: declare the return variable in the first line of the function body, rather than in the signature:

// cpp2 code
cpp2_func: () -> (w: widget) = {
    w = foo();
}

// translated into cpp1, assuming NRVO is available
auto cpp2_func() -> widget {
    auto w = foo();
    return w;       // We also need to make sure that returns are explicit
}

This transformation is easier to implement, because it means that call sites don't need to be altered.

Of course, if you need this transformation to guarantee that no copies/moves are performed, P2025 (or something similar) would need to be standardized. Until then, the only transformation that is portable is the one you described.

@JohelEGP
Copy link
Contributor

I think this is a more correct translation:

// translated into cpp1, assuming NRVO is available
struct cpp2_func__ret {
    widget w;
}
auto cpp2_func() -> cpp2_func__ret {
    auto res = cpp2_func__ret{foo()};
    return res;       // We also need to make sure that returns are explicit
}

Although to not require that all data members are initialized at the same time
(to not break today's use cases for the existing feature):

// cpp2 code
cpp2_func: (b: bool) -> (w: widget, v: widget) = {
    w = foo();
    stuff();
    if b {
        v = bar();
    } else {
        v = baz();
    }
}
// translated into cpp1, assuming NRVO is available
struct cpp2_func__ret {
    union { widget w; };
    union { widget v; };
}
auto cpp2_func(bool const& b) -> cpp2_func__ret {
    auto res = cpp2_func__ret{};
    res.w = foo();
    stuff();
    if (b) {
        res.v = bar();
    } else {
        res.v = baz();
    }
    return res;       // We also need to make sure that returns are explicit
}

Different from today is that those initializations are not runtime checked.

@JohelEGP
Copy link
Contributor

JohelEGP commented Jul 15, 2023

FWIW, this works (https://compiler-explorer.com/z/oKEd86qfY):

#include "https://raw.githubusercontent.com/hsutter/cppfront/main/include/cpp2util.h"
#include <cassert>

namespace cpp2 {
template<typename T>
class out_member {
    T* t;

    //  Each out in a chain contains its own uncaught_count.
    int  uncaught_count   = Uncaught_exceptions();
    bool called_construct = false;

public:
    out_member(T* t_) noexcept : t{t_} { Default.expects(t); }

    //  In the case of an exception, if the parameter was uninitialized
    //  then leave it in the same state on exit (strong guarantee)
    ~out_member() {
        if (called_construct && uncaught_count != Uncaught_exceptions()) {
            std::destroy_at(t);
        }
    }

    auto construct(auto&& ...args) -> void {
        if constexpr (requires { std::construct_at(t, CPP2_FORWARD(args)...); }) {
            Default.expects( t );
            std::construct_at(t, CPP2_FORWARD(args)...);
        }
        else {
            Default.expects(!"attempted to copy assign, but copy assignment is not available");
        }
    }

    auto construct_list(auto&& ...args) -> void {
        if constexpr (requires { std::construct_at(t, T{CPP2_FORWARD(args)...}); }) {
            Default.expects( t );
            std::construct_at(t, T{CPP2_FORWARD(args)...});
        }
        else {
            Default.expects(!"attempted to copy assign, but copy assignment is not available");
        }
    }

    auto value() noexcept -> T& {
        Default.expects( t );
        return *t;
    }
};
}

struct widget {
  std::string x;
  ~widget() { }
};
widget foo() { return {"1"}; }
widget bar() { return {"2"}; }
widget baz() { return {"3"}; }
void stuff() { }

struct cpp2_func__ret;
// Reenable structured bindings support.
#include <tuple>
template<> struct std::tuple_size<cpp2_func__ret> : std::integral_constant<int, 2> { };
template<> struct std::tuple_element<0, cpp2_func__ret> : std::type_identity<widget> { };
template<> struct std::tuple_element<1, cpp2_func__ret> : std::type_identity<widget> { };
struct cpp2_func__ret { // No longer an aggregate.
    union { widget w; }; // Now an anonymous union member.
    union { widget v; };
private: // For `cpp2_func` access.
    friend auto cpp2_func(bool const& b) -> cpp2_func__ret;
    cpp2_func__ret() { }
private:
    template<class T> void move_construct(T&& that) noexcept(std::is_rvalue_reference_v<T&&>) {
      auto _w = cpp2::out_member<widget>{&w};
      auto _v = cpp2::out_member<widget>{&v};
      _w.construct(std::move(that).w);
      _v.construct(std::move(that).v);
    }
    template<class T> cpp2_func__ret& move_assign(T&& that) noexcept(std::is_rvalue_reference_v<T&&>) {
      w = std::move(that).w;
      v = std::move(that).v;
      return *this;
    }
public:
    cpp2_func__ret(const cpp2_func__ret& that) { move_construct(that); }
    cpp2_func__ret(cpp2_func__ret&& that) noexcept { move_construct(std::move(that)); }
    cpp2_func__ret& operator=(const cpp2_func__ret& that) { return move_assign(that); }
    cpp2_func__ret& operator=(cpp2_func__ret&& that) noexcept { return move_assign(std::move(that)); }
    ~cpp2_func__ret() {
      std::destroy_at(&v);
      std::destroy_at(&w);
    }
    void f() {
      auto[a,b] = *this; // Test that structured bindings works at type scope.
    }
    // Reenable structured bindings support.
    template<int I, class T> requires (I==0) friend auto get(T&& x) -> decltype((CPP2_FORWARD(x).w)) { return CPP2_FORWARD(x).w; }
    template<int I, class T> requires (I==1) friend auto get(T&& x) -> decltype((CPP2_FORWARD(x).v)) { return CPP2_FORWARD(x).v; }
};

auto cpp2_func(bool const& b) -> cpp2_func__ret {
    auto __res = cpp2_func__ret{};
    auto w = cpp2::out_member<widget>{&__res.w};
    auto v = cpp2::out_member<widget>{&__res.v};
    w.construct(foo());
    stuff();
    if (b) {
        v.construct(bar());
    } else {
        v.construct(baz());
    }
    return __res;
}

int main() {
  auto [w, v] = cpp2_func(true);
  assert(w.x == "1");
  assert(v.x == "2");
}

I don't know what else could be broken by switching to anonymous union members.

@JohelEGP
Copy link
Contributor

JohelEGP commented Jul 15, 2023

Summarizing Herb's #540 (comment):

cpp2_func: () -> widget = { /*…*/ }
[…] the suggestion […] for cpp2_func to emit Cpp1 auto cpp2_func( widget& ) […]

Although the issue author's reply #540 (comment) is:

Yes, I think the code transformation would work roughly as you describe.

Because (N)RVO happens via the return value,
as the sample code that follows it suggests,
it should have been something like:

cpp2_func: () -> (w: widget) = { /*…*/ }
[…] the suggestion […] for cpp2_func to emit Cpp1

struct __ret_t { widget w; };
auto cpp2_func() -> __ret_t

[…]


FWIW, as a thought experiment, what if there were no return values at all and only out parameters were allowed, either as they are currently implemented or possibly with an updated implementation -- what would/wouldn't work in the use cases you have in mind?

operator=: (out this, …) would be unified with any other
f: (out x, …):

x: t = (…);
x    = (…);
x: t = f(…); // Now possible.
x    = f(…);

Whereas on the main branch:

// `operator=` remains the same.
x: t; // Can't initialize with `f(…)`.
f(out x);

@jcanizales
Copy link

what would/wouldn't work in the use cases you have in mind?

This one's easy. You can easily/naturally chain function calls when f(x) represents the output values (g(f(x))), but not when they're written result: T; f(x, out result)

A follow-up that's pertinent to the multiple named return values case, is that it opens the language syntax to the possibility of splatting when chaining function calls. This is something other languages have had for a while (e.g. in Julia: f(g(x)...) will fill up multiple parameters of f correctly if g returns a named tuple).

Repository owner locked and limited conversation to collaborators Aug 30, 2023
@hsutter hsutter converted this issue into discussion #626 Aug 30, 2023

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Projects
None yet
Development

No branches or pull requests

6 participants