hsutter / cppfront

A personal experimental C++ Syntax 2 -> Syntax 1 compiler
Other
5.42k stars 236 forks source link

[BUG] `constexpr` without `==` #761

Open JohelEGP opened 10 months ago

JohelEGP commented 10 months ago

Title: constexpr without ==.

Description:

In reading P2996R0 Reflection for C++26 2.3 List of Types to List of Sizes, I couldn't help but wonder, how would we declare an uninitialized constexpr variable in Cpp2. This isn't a thing in Cpp1, yet. But I think it's been mentioned as a possible relaxation to constexpr, including modifiable constexpr globals (per TU?).

But there's actually a context that already affects Cpp2. An @interface with a constexpr function (https://compiler-explorer.com/z/aro9K15b1):

struct X {
  constexpr virtual char f() const = 0;
};
struct Y : X {
  constexpr char f() const override { return 'Y'; }
};
constexpr char call_f(const X* x) { return x->f(); }
static_assert([y=Y{}] { return call_f(&y); }() == 'Y');

In Cpp2, the best approximation is (https://cpp2.godbolt.org/z/7Gha4YdK7):

X: @interface type = {
  f: (this) -> char;
}
Y: type = {
  this: X = ();
  operator=: (out this) == { }
  f: (override this) -> char == 'Y';
}
call_f: (x: * const X) -> char == x*.f();
main: () = {
  static_assert(:() call_f(Y()$&);() == 'Y');
}

// Hack for `constexpr`.
namespace cpp2 {
constexpr auto assert_not_null(auto&& p CPP2_SOURCE_LOCATION_PARAM_WITH_DEFAULT) -> decltype(auto) requires true
{
    return CPP2_FORWARD(p);
}
}

But that errors with:

main.cpp2:11:17: error: static assertion expression is not an integral constant expression
   11 |   static_assert([_0 = Y()]() -> auto { return call_f(&_0);  }() == 'Y');
      |                 ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
main.cpp2:11:17: note: non-literal type '(lambda at main.cpp2:11:17)' cannot be used in a constant expression
1 error generated.

It's not possible for X to declare a constexpr default constructor or f. We could attempt using @polymorphic_base. In Cpp1 (https://compiler-explorer.com/z/Y653WPM7j):

struct X {
  constexpr virtual char f() const { return 'X'; }
};
struct Y : X {
  constexpr char f() const override { return 'Y'; }
};
constexpr char call_f(const X* x) { return x->f(); }
static_assert([x=X{}] { return call_f(&x); }() == 'X');
static_assert([y=Y{}] { return call_f(&y); }() == 'Y');

In Cpp2 (https://cpp2.godbolt.org/z/4MYv5E9vz):

X: @polymorphic_base type = {
  operator=: (out this) == { }
  operator=: (virtual move this) == { }
  f: (virtual this) -> char == 'X';
}
Y: type = {
  this: X = ();
  operator=: (out this) == { }
  f: (override this) -> char == 'Y';
}
call_f: (x: * const X) -> char == x*.f();
main: () = {
  static_assert(:() call_f(X()$&);() == 'X');
  static_assert(:() call_f(Y()$&);() == 'Y');
}

// Hack for `constexpr`.
namespace cpp2 {
constexpr auto assert_not_null(auto&& p CPP2_SOURCE_LOCATION_PARAM_WITH_DEFAULT) -> decltype(auto) requires true
{
    return CPP2_FORWARD(p);
}
}

That's a big jump in the API of X (a variable can be instantiated, calling f on isn't a compile-time error, calling std::terminate() in f helps at compile-time but otherwise delays to runtime). And it happens to work because the return type is a mere char (it could be a more complicated data type, the data-less polymorphic base might need the help of globals).

Minimal reproducer (https://cpp2.godbolt.org/z/7Gha4YdK7):

X: @interface type = {
  f: (this) -> char;
}
Y: type = {
  this: X = ();
  operator=: (out this) == { }
  f: (override this) -> char == 'Y';
}
call_f: (x: * const X) -> char == x*.f();
main: () = {
  static_assert(:() call_f(Y()$&);() == 'Y');
}

// Hack for `constexpr`.
namespace cpp2 {
constexpr auto assert_not_null(auto&& p CPP2_SOURCE_LOCATION_PARAM_WITH_DEFAULT) -> decltype(auto) requires true
{
    return CPP2_FORWARD(p);
}
}

Commands: ```bash cppfront main.cpp2 clang++18 -std=c++23 -stdlib=libc++ -lc++abi -pedantic-errors -Wall -Wextra -Wconversion -Werror=unused-result -I . main.cpp ```

Expected result:

Consideration to constexpr without ==. It might be relevant to some users today. Cpp1 language evolution might also make it very relevant.

Herb has mentioned limiting global variables to constexpr. That could make x: int; at namespace scope an uninitialized constexpr variable. But that wouldn't help with other entities that can be left uninitialized.

Actual result and error:

Cpp2 lowered to Cpp1: ```C++ //=== Cpp2 type declarations ==================================================== #include "cpp2util.h" class X; class Y; //=== Cpp2 type definitions and function declarations =========================== class X { public: [[nodiscard]] virtual auto f() const -> char = 0; public: virtual ~X() noexcept; public: X() = default; public: X(X const&) = delete; /* No 'that' constructor, suppress copy */ public: auto operator=(X const&) -> void = delete; }; class Y: public X { public: constexpr explicit Y(); public: [[nodiscard]] constexpr auto f() const -> char override; public: Y(Y const&) = delete; /* No 'that' constructor, suppress copy */ public: auto operator=(Y const&) -> void = delete; }; [[nodiscard]] constexpr auto call_f(X const* x) -> char; auto main() -> int; //=== Cpp2 function definitions ================================================= X::~X() noexcept{} constexpr Y::Y() : X{ } {} [[nodiscard]] constexpr auto Y::f() const -> char { return 'Y'; } [[nodiscard]] constexpr auto call_f(X const* x) -> char { return CPP2_UFCS_0(f, (*cpp2::assert_not_null(x))); } auto main() -> int{ static_assert([_0 = Y()]() -> auto { return call_f(&_0); }() == 'Y'); } ```

Output: ```output main.cpp2:11:17: error: static assertion expression is not an integral constant expression 11 | static_assert([_0 = Y()]() -> auto { return call_f(&_0); }() == 'Y'); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ main.cpp2:11:17: note: non-literal type '(lambda at main.cpp2:11:17)' cannot be used in a constant expression 1 error generated. ```

See also:

JohelEGP commented 10 months ago

This could be considered a sufficiently niche issue for the sake of the general simplicity of Cpp2.

There's similar issues with the mapping of Cpp1 specifiers to Cpp2.

The last one is for performance. Cpp2 has no opt-in and Cppfront doesn't lower to it, so we're backwards-compatible with Cpp1. If static was the default, we'd lose backwards-compatibility. An explicit this parameter (not currently supported) would lower to a generic lambda, and its function pointers are not member function pointers. The only truly backwards-compatible way would be to explicitly opt-into static. So, for performance, maybe we'd need something like a static virtual-specifier.

JohelEGP commented 10 months ago

From https://wg21.link/p2996r0#enum-to-string:

  template for (constexpr auto e : std::meta::members_of(^E)) {

The non-template for equivalent in Cpp2 is:

for range do (e) {

A way to support the proposed Cpp1 template for is with Cpp2 template for. Or, alternatively, if we could specify e as constexpr somehow. Although, arguably, the template keyword upfront is a good indicator of the loop's nature.

Another proposed syntax was for..., but that doesn't work for reflection. std::meta::members_of(^E) is not a pack, but a range to perform heterogeneous splicing.

JohelEGP commented 10 months ago

It also seems that constexpr with == doesn't compose. Only a subset of the built-in metafunctions generate constexpr functions. The built-in metafunctions are

interface, polymorphic_base, ordered, weakly_ordered, partially_ordered, copyable, basic_value, value, weakly_ordered_value, partially_ordered_value, struct, enum, flag_enum, union, print

The ordering metafunctions will be constexpr because they lower to =defaulted functions. In commit b589f5d25e5acdbfd94791e23c0ba0f6fdcd59c6, the enum metafunctions had to stop using basic_value to explicitly opt-into the == syntax for constexpr.

Only interface and polymorphic_base are really sensitive to constexpr. You can only use virtual dispatch from a base pointer if its function is constexpr.

The copyable and value metafunctions never generate constexpr functions. Since P2448R2, those can simply be constexpr functions. From https://en.cppreference.com/w/cpp/compiler_support:

C++23 feature Paper(s) GCC Clang MSVC Apple Clang
Relaxing some constexpr restrictions P2448R2 13 17 (partial)

Or maybe constexpr should still be considered a contract that requires explicit opt-in. Just like how operator= works. I've considered a @constexpr type metafunction to mark all member functions as constexpr. But that might be too broad (e.g., @enum @constexpr would mark streaming operator<< as constexpr). I've also thought of giving an argument to copyable and the value metafunctions to opt-into constexpr.

JohelEGP commented 10 months ago

I've considered a @constexpr type metafunction to mark all member functions as constexpr. But that might be too broad (e.g., @enum @constexpr would mark streaming operator<< as constexpr). I've also thought of giving an argument to copyable and the value metafunctions to opt-into constexpr.

Maybe @constexpr should be a variadic meta-metafunction. It would take the metafunctions to invoke and mark the added members as constexpr. The reflection API would have to be extended to allow declaring never-constexpr member functions. For example:

JohelEGP commented 10 months ago

Maybe @constexpr should be a variadic meta-metafunction.

Doesn't seem possible without extending the reflection API. Metafunction template arguments are just strings.

JohelEGP commented 7 months ago

See also #959.