chromium / subspace

A concept-centered standard library for C++20, enabling safer and more reliable products and a more modern feel for C++ code.; Also home of Subdoc the code-documentation generator.
https://suslib.cc
Apache License 2.0
89 stars 15 forks source link

Delete consteval constructors #266

Closed danakj closed 1 year ago

danakj commented 1 year ago

This is sad. But they break compilation.

They are a constructor so they should let you construct stuff with them.

struct S { consteval S(int); };
S(1);

This works fine cuz we're calling the constructor directly. But if there's any indirection in between, the constructor becomes unusable as you now have a reference, but still remains callable even though you're now in a runtime context. And this just produces a compiler error.

One consequence is that it's impossible to construct the above S from a function that takes a variable number of arguments. Varargs are implemented as templated types, and you can specified constructible_from<S, Ts> for each Ts but you receive a Ts&& and now when you call S::S() with that reference, the compiler generates an error.

See https://sunny.garden/@blinkygal/110557258312870357 for more sadness about this.

It was really nice to compile-time convert from (signed) 1 to u32 but it just breaks in completely surprising and difficult ways to understand for a user. Better to not work at all I think.

danakj commented 1 year ago

Here's a more coherent explanation of the deficiencies of consteval in C++20. We'll look at it from the perspective of constructors, where it's more obvious, but the same applies to any function.

tl;dr consteval does not participate in overload resolution, consteval is not visible to SFINAE, and consteval. So it breaks templated code and your users will have to work around it.

Setup

Here's a type, it can be constructed in a constant expression from an int or a float. At runtime it can not be constructed from int.

struct S {
    // Compile-time or run-time.
    constexpr S(float) {}

    // Compile-time only.
    consteval S(int) {}
};

What works

We can construct S from an int literal, which is known at compile time. We can also construct it from a constexpr variable.

auto v1 = S(1);
constexpr auto c =1;
auto v2 = S(c);

We can receive S by value in a non-constexpr function and pass an int literal to construct the argument.

void take_s(S) {}

void f() {
    take_s(1);
}

What works in one compiler

We can construct S from a constexpr template function, in clang anyway. In MSVC this is a compiler error error C7595: 'S::S': call to immediate function is not a constant expression inside each of the make_s1 and make_s2 functions. In GCC this is compiler error error: 'i' is not a constant expression inside each function.

template <class T>
constexpr S make_s1(T i) { return S(i); }
template <class T>
constexpr S make_s2(T&& i) { return S(std::forward<T>(i)); }

void f() {
  auto v1 = make_s1(1);
  auto v2 = make_s2(1);
}

What does not work

If your constexpr function has a path that calls a non-constexpr function (in fact soon all paths can be non-constexpr and will be accepted), and you forward your template type, things break.

void take_s(S s) {}

template <class T>
constexpr void make_s1(T i) { if (i < 10) take_s(i); }
template <class T>
constexpr void make_s2(T&& i) { if (i < 10) take_s(std::forward<T>(i)); }

void f() {
  // error: call to immediate function 'make_s1<int>' is not a constant expression
  make_s1(1);
  // error: call to immediate function 'make_s2<int>' is not a constant expression
  make_s2(1);
}

In clang, at least the error is at the caller of make_s1 and make_s2, whereas in MSVC it ends up inside the body of those functions.

There's no way to ask if S is constructible in a runtime context. The type_traits library only answers about compile time (constant evaluation). So there's no way to interact with the caller in SFINAE and have them choose a different path. There is only compile error.

This gets much more obvious when you want to write generic types. Here's a class that's templated on T.

template <class T>
struct Holder {
  Holder(T u) { T{u}; }
};

This works because we're receiving T by value.

void f() {
    Holder<S>(1);
}

But we may want to receive parameters that are convertible_to the template type T without converting them yet, rather than receiving them by value. This is a pretty common idiom in C++ due to the lack of trivial relocation. Now this fails to compile, because the construction of S as moved into the Holder constructor.

template <class T>
struct Holder {
  template <std::convertible_to<T> U>
  Holder(U&& u) {
    // error: call to consteval function 'S::S' is not a constant expression
    T{std::forward<U>(u)};
  }
};

void f() {
    Holder<S>(1);
}

And again, there's no way for f() to determine that it can't construct Holder in this way. There's no way for Holder to provide a different constructor overload for runtime instead of compile time.

Thus my very modern and clever type S with its use of consteval can not integrate into any code that contains non-constexpr functions. And not because S isn't usable in a runtime context, but because the language is not expressive enough to expose the consteval requirement in a dynamic way, or to provide alternate paths. We could use std::is_constant_evaluated() to use a run-time constructor if we could tell that something is runtime-constructible but not compile-time-constructible.

Not that I am wishing for yet even more constructible type traits. The situation with constant evaluation in C++ is... complicated. I'd take procedural macros instead any day. I kind of just want consteval to be something you can overload on with non-constexpr (but not with constexpr), so that you can provide a compile-time path and a run-time path. But then I'd want to = delete the runtime one sometimes and there'd still be no way to see that in type_traits.

I am going to stop using consteval for now.

danakj commented 1 year ago

Here's the specific code that works without consteval:

TEST(Successors, Example) {
  auto powers_of_10 = sus::iter::Successors<u16>::with(
      sus::some(1_u16), [](const u16& n) { return n.checked_mul(10_u16); });
  sus::check(
      sus::move(powers_of_10).collect<Vec<u16>>() ==
      sus::Vec<u16>::with_values(1_u16, 10_u16, 100_u16, 1000_u16, 10000_u16));
}

And here's what I want it to look like, using consteval constructors to reject invalid values at compile time:

TEST(Successors, Example) {
  auto powers_of_10 = sus::iter::Successors<u16>::with(
      sus::some(1), [](const u16& n) { return n.checked_mul(10); });
  sus::check(
      sus::move(powers_of_10).collect<Vec<u16>>() ==
      sus::Vec<u16>::with_values(1, 10, 100, 1000, 10000));
}

Unfortunately some() holds an int&& and its constexpr operator Option<i16>() fails to compile constructing the i16 from the int&&.

And Vec<u16> takes variadic args Ts&&... which are convertible_to<u16> except they are not when the function runs and it fails to compile.