Skip to content

be-cpp: evaluate call operands left to right - #601

Open
notactuallytreyanastasio wants to merge 1 commit into
temperlang:mainfrom
notactuallytreyanastasio:fix/cpp-argument-order
Open

notactuallytreyanastasio wants to merge 1 commit into
temperlang:mainfrom
notactuallytreyanastasio:fix/cpp-argument-order

Conversation

@notactuallytreyanastasio

Copy link
Copy Markdown
Contributor

C++ does not fix the order of a call's arguments, of the object a method
is called on relative to its arguments, or of an operator's operands,
and x86-64 g++ 14 evaluates arguments right to left. be-cpp emitted
every Temper call, operator and interpolation as one C++ call, so a
read after a call saw the state from before it. A probe under x86-64
g++ 14 in Docker, before and after (js prints the "after" column):

bump=1 n=0              bump=1 n=1
args 2 1                args 2 2
plus 32                 plus 33
inside 4 4              inside 3 4
before 5 5              before 4 5
receiver 606            receiver 606 ab
captured 6 6 1 0        captured 0 5 6 6

Apple clang happens to go left to right, which is why this did not show
on macOS.

When one operand may act (call user code, a getter, a cast, or support
code that writes or takes a callback) and another operand is anything
but a constant or an unassignable local, the operands are copied to
locals in order inside a lambda called in place, and the call is built
from those locals:

temper::core::Console::log(console_0, temper::core::cat("args ", [&]() -> auto {
      auto arg_3 = c->bump();
      auto arg_4 = c->get_n();
      return show(arg_3, arg_4);
    }()));

Statement-level hoisting would read better, but it would move an operand
out from under &&, || and the conditionals the frontend lowers to, so
it would run code the source says may not run. The lambda keeps each
operand where it was. The locals are copies, not auto&&: a reference
to this_1->n would see the write that the next operand makes to it.

Everything else is unchanged, which is most code: no golden output in
CppBackendTest changed, and the generated std modules contain none of
these lambdas. Telling the cases apart needs to know what an operand can
do, so:

  • CppInlineSupportCode gets a SupportEffect. Support code is Acts unless
    marked Pure (arithmetic, comparisons, cat, toString, constructors of
    empty collections) or Reads (List::get, String::get, Int::div_wrap and
    others that only read or can fail). Two Reads can run in either order;
    a failure is a failure whichever comes first.
  • A var local counts as changeable only if a closure uses it, since
    closures are the only way a call can assign it. Module-level vars,
    imported names and properties that are not assign-once count too.
  • A getter call counts as a plain read when the subject's static type
    is a class and the property is backed, since classes cannot be
    extended and the generated getter only returns the field.

The grammar gains InOrderExpr and InOrderTemp (Cpp.kt is regenerated),
and the formatter keeps the calling ( on the line of the lambda's }.
A static property's initializer is defined at namespace scope, where a
lambda may not have a capture default (g++: "non-local lambda expression
cannot have a capture-default"), so there the lambda is []:

int32_t S::t = []() -> auto {
  auto arg_3 = temper::core::Int::mul(Counter::make()->bump(), 10);
  auto arg_4 = Counter::make()->bump();
  return temper::core::Int::add(arg_3, arg_4);
}();

CppBackendTest.operandsThatCanAffectEachOtherAreBoundInOrder and
operandsOfAStaticInitializerAreBoundWithoutCaptures fail before this
change and pass after it. The cpp functional tests pass
with clang; regexZeroAdvance fails on origin/main and still does. Not
changed: be-cppv, which has its own translator and was not checked.

Fixes #539.

Repro: https://github.com/notactuallytreyanastasio/temper-issue-repros/tree/main/18-cpp-argument-order

🤖 Generated with Claude Code

C++ does not fix the order of a call's arguments, of the object a method
is called on relative to its arguments, or of an operator's operands,
and x86-64 g++ 14 evaluates arguments right to left. be-cpp emitted
every Temper call, operator and interpolation as one C++ call, so a
read after a call saw the state from before it. A probe under x86-64
g++ 14 in Docker, before and after (js prints the "after" column):

    bump=1 n=0              bump=1 n=1
    args 2 1                args 2 2
    plus 32                 plus 33
    inside 4 4              inside 3 4
    before 5 5              before 4 5
    receiver 606            receiver 606 ab
    captured 6 6 1 0        captured 0 5 6 6

Apple clang happens to go left to right, which is why this did not show
on macOS.

When one operand may act (call user code, a getter, a cast, or support
code that writes or takes a callback) and another operand is anything
but a constant or an unassignable local, the operands are copied to
locals in order inside a lambda called in place, and the call is built
from those locals:

    temper::core::Console::log(console_0, temper::core::cat("args ", [&]() -> auto {
          auto arg_3 = c->bump();
          auto arg_4 = c->get_n();
          return show(arg_3, arg_4);
        }()));

Statement-level hoisting would read better, but it would move an operand
out from under `&&`, `||` and the conditionals the frontend lowers to, so
it would run code the source says may not run. The lambda keeps each
operand where it was. The locals are copies, not `auto&&`: a reference
to `this_1->n` would see the write that the next operand makes to it.

Everything else is unchanged, which is most code: no golden output in
CppBackendTest changed, and the generated std modules contain none of
these lambdas. Telling the cases apart needs to know what an operand can
do, so:

- CppInlineSupportCode gets a SupportEffect. Support code is Acts unless
  marked Pure (arithmetic, comparisons, cat, toString, constructors of
  empty collections) or Reads (List::get, String::get, Int::div_wrap and
  others that only read or can fail). Two Reads can run in either order;
  a failure is a failure whichever comes first.
- A `var` local counts as changeable only if a closure uses it, since
  closures are the only way a call can assign it. Module-level vars,
  imported names and properties that are not assign-once count too.
- A getter call counts as a plain read when the subject's static type
  is a class and the property is backed, since classes cannot be
  extended and the generated getter only returns the field.

The grammar gains InOrderExpr and InOrderTemp (Cpp.kt is regenerated),
and the formatter keeps the calling `(` on the line of the lambda's `}`.
A static property's initializer is defined at namespace scope, where a
lambda may not have a capture default (g++: "non-local lambda expression
cannot have a capture-default"), so there the lambda is `[]`:

    int32_t S::t = []() -> auto {
      auto arg_3 = temper::core::Int::mul(Counter::make()->bump(), 10);
      auto arg_4 = Counter::make()->bump();
      return temper::core::Int::add(arg_3, arg_4);
    }();

CppBackendTest.operandsThatCanAffectEachOtherAreBoundInOrder and
operandsOfAStaticInitializerAreBoundWithoutCaptures fail before this
change and pass after it. The cpp functional tests pass
with clang; regexZeroAdvance fails on origin/main and still does. Not
changed: be-cppv, which has its own translator and was not checked.

Fixes temperlang#539.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Bobby Grayson <bobbbygrayson+github@gmail.com>
@mikesamuel
mikesamuel requested a review from ShawSumma October 12, 2026 18:33
@mikesamuel

Copy link
Copy Markdown
Contributor

@ShawSumma tagging you on this since you're most familiar with be-cpp.

I thought we had at least some coverage of OoO in functional tests. Something like f(++x, --x, x++) kind of stuff. Maybe FunctionalTestStatus is excluding that for be-cpp and no longer needs to?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cpp: call arguments run right to left under x86-64 g++, so a read after a call sees the old value

2 participants