§Reflaxe/C++ Devlog #1 - CallStack

May 15, 2023

by Robert Borghese

Padding

GithubIcon

View on Github

§Intro

It wasn't until I started delving into the dreaded Dynamic type that I realized Reflaxe/C++ lacked a proper error reporting system. Throwing an error doesn't even print a message in C++; it just hangs for like a minute then exits with a random 12 digit exit code. So before I continued with implementing a runtime type system with Haxe's Dynamic, I needed to make runtime errors look nicer and track easier for my own sanity.

The first part was easy. Instead of compiling Haxe throws as native C++ throws, I could instead print whatever the Error object was to std::err (with its Haxe position), run exit(666), and call it a day. Simply add a define to decide whether the user wants native throws or these simulated ones and it's no biggie... except for the fact that wouldn't be compatible with try/catch. Ooof.

// native throw
throw "my runtime error";

// stderr throw
std::err << "Main.hx:32: my runtime error" << std::endl;
exit(666);

It would be nice to ONLY print/display errors on uncaught throws... achieve something similar to Hashlink's nice error popup system... but that's just not feasible when generating C++ source code. Oh well, native throws stay for now, I guess. I'll just wrap everything with try { ... } while testing.

§The Call Stack Problem

Speaking of errors, taking a look into it made me realize the haxe.Exception class wasn't fully implemented for Reflaxe/C++. It had a bare-bones version made to help things run riiiiight at the start of this project's development, and now was the perfect time to finish it. It was all fine and dandy except for stack. A field which provided the haxe.CallStack object for the error.

For those that don't know, the haxe.CallStack class is a wonderful debugging class that provides the entire list of calls made up to that point:

trace(haxe.CallStack.toString(haxe.CallStack.callStack()));

Similar to the Exception class, haxe.CallStack had been given a very early implementation to make things work, and then it was completely forgotten about. But unlike Exception, it was not going to be as fun to complete.

Ugh. If only C++ had a built in call stack like every other target.

The funny part is, it actually does. With the release of C++23, a new class was added: std::basic_stacktrace. Sooooo... I guess time to make C++23 the minimum required version!!

Haha, I wish. Making C++17 the minimum version killed me; going any higher and this project would be less compatible than the original Haxe/C++ target. X_X

So... time for option 2.

§Making a Call Stack

When you really think about it though, tracking function calls isn't that hard. After all, C++'s destructors are a great tool for tracking scopes!

Make a class that adds itself to a global stack when constructed, then removes itself using the destructor. When generating the C++, create an instance of that class at the start of every function. Pass it the name of the class + function it's being used in and BOOM, you have a global stack you can access at anywhere!

struct StackTracer {
  static vector<StackTracer*> Stack;

  StackTracer(string cls, string method) {
    // store cls + method
    Stack.push_back(this);
  }

  ~StackTracer() { Stack.pop_back(); }
}

// ---

// some function
void doSomething() {
  StackTracer st("Main", "doSomething"); // will destroy when func done
  // do stuff
}

§The Haxe Target Call Stack Experience

To actually implement the call stack, we have to convert our custom stack data into Haxe's. Thankfully, there's this super convenient hidden class that exists just for this purpose: NativeStackTrace.

All we gotta do is implement a version of this class with the functions filled out in our target's _std folder.

extern class NativeStackTrace {
	static public function saveStack(exception:Any):Void;
	static public function callStack():Any;
	static public function exceptionStack():Any;
	static public function toHaxe(nativeStackTrace:Any, skip:Int = 0):Array<StackItem>;
}

The Any types can be replaced with our own target's unique call stack list type. For example, C# uses System.Diagnostics.StackTrace and Python uses an Array of tuples. From there, we implement functions to generate the stack from an exception or an arbitrary call, and finally we write the toHaxe function to convert it.

What was especially interesting was discovering how inconsistent call stacks are for each target. While Haxe provides an enum for structuring the call stack in the form of haxe.StackItem, the way each case is used is different for each target.

Going to the Haxe Playground and clicking between each target, you can see how the call stack differs drastically:

class Test {
  static function main() {
    trace(haxe.CallStack.toString(haxe.CallStack.callStack()));
  }
}
JavaScript
HashLink

Test.hx:3: Called from haxe.CallStack.$CallStack_Impl.callStack (/home/haxer/haxe/versions/4.3.1/std/haxe/CallStack.hx line 52) Called from $Test.main (Test.hx line 3)

Eval

Test.hx:3: Called from Test.main (Test.hx line 3 column 35)

In fact, most targets only appear to use Method and FilePos from haxe.StackItem. The rest are for niche cases with certain targets.

This was a nice change of pace from the usual, restrictive nature of the Haxe API, as this would allow me to make sure the Reflaxe/C++ call stack was as nice as possible.

§Line tracking

While filling out the StackItems, it became clear I'd forgetten something. Call stacks do not just require a list of functions, but the exact line they're called from.

Taking a peak into hxcpp, it appears their solution is to do what I've done so far, but also update the function's stack tracing variable every expression. THAT'S what all those ugly HXLINE do (guess it was kinda obvious).

It wouldn't be too hard to implement that as well... and it's not like I have much of a choice, but wooooow it destroys the beautiful Reflaxe/C++ code I had so much pride in.

But in the end, I implemented it. All it took was overriding Reflaxe's prefixExpressionContent function and generating content before every expression was a piece of cake.

§Final Conversion

I was worried about the way local functions would be printed, but as discussed before, we only need to use the StackItems we need. Like with the Haxe/Python target, I opted to the FilePos(Method(...), ...) combo for everything.

Method allows for any text to be added for the class and function, so even in rare cases like local functions, I was able to generate a nice, custom format that stood supreme to other targets:

class Test {
  static function main() {
    function a() {
      final b = () ->
        trace(haxe.CallStack.toString(haxe.CallStack.callStack()));
      b();
    }
    a();
  }
}
Called from Test.main.a.b (Test.hx line 5 column 3)
Called from Test.main.a (Test.hx line 4 column 2)
Called from Test.main (Test.hx line 3 column 1)

§Result

Since Reflaxe/C++ does not use any dependencies, all the code (both the C++ implementation and Haxe externs) is implemented in the NativeStackTrace module.

You can check it out here!

Reflaxe's DCE system is powerful; you only pay for what you use. Unfortunately... it looks like you'll always be using the NativeStackTrace code.

Reflaxe/C++'s once beautiful "Hello World" output now looks like this:

void _Main::Main_Fields_::main() {
  HCXX_STACK_METHOD("Main.hx", 1, 0, "_Main::Main_Fields_", "main")

  HCXX_LINE(1)
  std::cout << "Main.hx:2: Hello world!" << std::endl;
}

§Opt In Option

In the end, I decided to disable call stack features by default. Reflaxe/C++ is a clean code generator first. Only when a certain define is enabled (-D cxx_callstack) does haxe.CallStack.callStack() return the stack. If it's not enabled, an empty array is returned and a message is printed:

Call stack features must be enabled using -D cxx_callstack

Hopefully this intrinsically teaches users the benefits of keeping the call stack enabled vs disabled. And more importantly, allow me to advertise Reflaxe/C++ as the prettiest C++ output in all the land!

§What's Left To Be done

I didn't bring up multi-threading support because... I didn't do anything about it. In the future, I'll need to keep separate call stacks for each thread. But threads have not been implemented yet, so that's a problem for future me!

Anyway, that's about it for this log. May you visit again!