Fun with Placeholder Expressions

The topic of this blog is an experimental language feature called Parametric Expressions. If you are not familiar with them, check out the previous post which covers the basics of how they work. The very first post in this series covered how parametric expressions can work with placeholder expressions to return unexpanded parameter packs. This post will cover some interesting use cases that don't involve packs.

Overloaded Functions

As stated before, placeholder expressions are intermediate expressions that don't have a type or refer to a value. The most obvious example of these is the name of an overloaded function.

Consider this TonyTable™:

auto result = reduce(std::forward<decltype(xs)>(xs),
  0,
  [](auto&& x, auto&& y) {
    return std::max(std::forward<decltype(x)>(x),
                    std::forward<decltype(y)>(y));
  });
auto result = reduce(fwd(xs), 0, std::max);
https://godbolt.org/z/qCThAR

The above demonstrates how we can pass an overloaded function without wrapping it in a lambda. The example isn't perfect because std::max doesn't need the forwarding as it works with const references, but I wanted to show it anyways and with a function in the standard library. I'm also cheating because I am just passing 0 for an initial or default value.

Here is the implementation of reduce and a helper lam that lifts things into a lambda so it can be passed as an invokable object.

using lam(using auto fn) {
  return [&](auto&& ...args) {
    return fn(fwd(args)...);
  };
}

using reduce(auto range, using auto init,
             using auto bin_op) {
  return std::reduce(fwd(range).begin(), fwd(range).end(),
                     init, lam(bin_op));
}

Once again, please disregard the use of auto in the declaration syntax. It was meant to be a placeholder for a constraint, and it does not specify a type. It could probably just go away, but we are rolling with it for now.

Remember that using params behave like a macro, so take note that range is not declared with the using specifier. This means that its input expression is evaluated and its result bound to a variable of reference type; otherwise it would be evaluated twice which is bad. Because it is stored, its input cannot be a placeholder expression.

Overloaded Member Functions

If we aren't wrapping another interface that requires an invokable object then we don't need to wrap in lam.

using for_each(auto range, using auto unary_op) {
  for (auto&& x : range) {
    unary_op(fwd(x));
  }
}

This for_each is setup to allow these placeholder expressions which includes overloaded member functions. We can still wrap these with lam where it might be needed.

struct foo {
  unsigned max = 0;
  void member(unsigned x) {
    max = std::max(max, x); 
  }

  void member() { }
} obj;

for_each(xs, obj.member);
for_each(xs, lam(obj.member));
https://godbolt.org/z/BDCeeI

Note in the implementation of lam we capture by reference. This wasn't required until now because the instance obj cannot be used inside a lambda without capturing it.

On Slack, Damian Jarek pointed out that a placeholder expression that is the name of a non-static member function without an instance (ie Foo::member) would not compile. The purpose was to capture an instance and apply the member function later like obj.Foo::member which is valid. After some digging we found that it would be impossible since the standard explicitly states that, without the base, these are always treated as a member access with implicit this.

So that particular format is an example of what cannot be input into a placeholder expression because the inputs are C++ expressions and not just arbitrary tokens.

List Initialization

With C++17 we get a very cool feature called Class Template Argument Deduction (CTAD) that allows implicitly supplying the template arguments to a class template based on the inputs to the constructor call.

Consider the following:

// direct-list-initialization
std::tuple xs{42, 5.0f, foo{5}};

// copy-list-initialization
std::tuple ys = {42, 5.0f, foo{5}};

The above are nice ways to create a tuple. They are the same, except copy-list-initialization explodes if it calls an explicit constructor which I don't think is a problem for std::tuple or std::pair. Also, I'm pretty sure there isn't any copying going on.

Because we can't juxtapose two identifiers, we can only use the latter to create an interface that allows taking init lists to create tuples.

Here is an example:

using init_tuple(using auto tup) {
  std::tuple xs = tup;
  return xs;
}

using make_tuples(using auto ...tups) {
  return std::tuple{init_tuple(tups)...};
}

With NRVO, I am pretty sure this would not create an intermediate copy. Here is how it could be used:

auto tuples = make_tuples(xs, ys, {84, 10.0f, foo{10}});
https://godbolt.org/z/rnCY8k

It's a bridge farther than what P1221 is proposing, but it would be cool to be able to have parametric expressions also work with constructors and CTAD to allow placeholder expressions within these init lists.

Summary

So far this series has demonstrated some very concrete things not really feasible with today's C++. For the next post, we will take a look at static members, operator overloading, and then jump into lazy evaluation.

Thanks for looking at this!

Jason Rice

Follow on Twitter
@JasonRice_

Parametric Expressions

Parametric Expressions are a hygienic macro like language feature proposed for C++.

Check it out! P1221