Make C++ a better place #2: CppFront as an alternative


In this article, we will explore how CppFront aims to make C++ a better place by introducing a new syntax, improving safety and usability and providing modern features that align with good programming practices – all while maintaining full interoperability with C++.

The origins and philosophy of CppFront

CppFront is Herb Sutter’s personal experiment to create a cleaner and more expressive layer on top of C++. The name CppFront pays homage to Cfront, the first C++ compiler, which translated C++ into C code. Similarly, CppFront generates C++ code from a higher-level syntax that adheres to modern best practices.

The philosophy of CppFront is simple: create a tool that generates state-of-the-art C++ code, filled with good practices such as trailing return types or the use of [[nodiscard]] by default. This generated C++ code can be compiled with any standard C++ compiler, ensuring compatibility with existing tool chains while pushing the language towards a safer and more intuitive future.

Producer/consumer implementation with CppFront

If you didn’t see the first article of this series, please read it because I explained there what are the things I want to check about each C++ alternative and how I’m going to do that.

Below you case see the implementation of the reference producer/consumer application written in CppFront:

// mutex for shared resource access synchronization
static std::shared_mutex mtx;
// condition variable for threads execution flow synchronization
static std::condition_variable_any cv;
// atomic variable to share a flag between the threads
static std::atomic<bool> is_finished = false;
// interface class
WorkerInterface: type = {
    operator=: (virtual move this) = {}
    Run: (virtual inout this);
}
// alias for a type
WorkerPtr: type == std::shared_ptr<WorkerInterface>;
// templated alias for a type
SharedContainerPtr: <T> type == std::shared_ptr<std::vector<T>>;
// template class definition
Producer: <T> type = {
    // inheritance
    this: WorkerInterface;
    // class constructor and members initialization
    // together with passing arguments by const reference and non-const reference
    operator=: (out this, in_ref source_data: std::array<T, 3>, copy shared_container: SharedContainerPtr<T>) = {
        WorkerInterface = ();
        source_data_ = source_data;
        shared_container_ = shared_container;
        // if statement
        if !shared_container_ {
            std::cout << "Given shared_container is a nullptr!" << std::endl;
        }
    }
    Run: (override inout this) = {
        // indexed for loop
        for 0..<10000U do (i) {
            // range based for loop
            for source_data_ do (element) {
                // print statement
                std::cout << "Putting (element)$ to the shared container" << std::endl;
                {
                    // blocking reading and writing from/to shared container
                    lock: std::unique_lock<std::shared_mutex> = (mtx);
                    // appending element to a variable-length array
                    shared_container_*.push_back(element);
                }
                // unblocking all waiting threads
                cv.notify_all();
            }
        }
        is_finished = true;
        cv.notify_all();
        std::cout << "Producer done" << std::endl;
    }
    // private class members
    source_data_: std::array<T, 3>;
    shared_container_: SharedContainerPtr<T>;
}

Consumer: <T> type = {
    this: WorkerInterface;
    operator=: (out this, id: u8, copy shared_container: SharedContainerPtr<T>) = {
        WorkerInterface = ();
        id_ = id;
        last_size_ = shared_container*.size();
        shared_container_ = shared_container;

        if !shared_container_ {
            std::cout << "Given shared_container is a nullptr!" << std::endl;
        }
    }
    Run: (override inout this) = {
        // infinite while loop
        while true {
            // blocking writing to the shared container
            lock: std::shared_lock<std::shared_mutex> = (mtx);
            // waiting for the input from another thread
            cv.wait(lock, :() -> bool = { return shared_container_*.size() > last_size_ || is_finished; });
            _ = lock;
            if is_finished {
                break;
            }
            std::cout << "Consumer (id_)$ noticed new element: (shared_container_*.back())$" << std::endl;
            last_size_ = shared_container_*.size();
        }

        std::cout << "Consumer (id_)$ done" << std::endl;
    }

    id_: u8;
    last_size_: std::size_t;
    shared_container_: SharedContainerPtr<T>;
}
// enum definition
WorkerType: @enum<u8> type = {
    kProducer := 0U;
    kConsumer := 1U;
}
// template function with a compile time argument and variadic arguments
CreateWorker: <worker_type: u8, Args...: type>(args...: Args) -> WorkerPtr = {
    // compile time if statement excluding constructors of the classes to which
    // current arguments don't fit - an ordinary runtime if would cause a compilation error
    // because Producer's and Consumer's constructors have different signatures
    if constexpr worker_type == 0U {
        // dynamic memory allocation of the polymorphic type
        return cpp2::shared.new<Producer<std::string>>(args...);
    }
    else if constexpr worker_type == 1U {
        return cpp2::shared.new<Consumer<std::string>>(args...);
    }
    else {
        std::cout << "Unsupported worker type: (worker_type)$" << std::endl;
    }
}
// application entry point
main: () = {
    // start measuring execution time
    start: const _ = std::chrono::high_resolution_clock::now();
    // separate scope to not have to call wait() on futures - in the C++ reference
    // implementation this is handled just by the try scope
    {
        // fixed-length array
        source_data: const std::array<std::string, 3> = ("Hello", "world", "!!!");
        // variable-length array
        message_container: std::vector<std::string> = ();
        shared_container: SharedContainerPtr<std::string> = cpp2::shared.new<std::vector<std::string>>(message_container);
        // creation of the polymorphic types
        producer: WorkerPtr = CreateWorker<0U>(source_data, shared_container);
        first_consumer: WorkerPtr = CreateWorker<1U>(0U, shared_container);
        second_consumer: WorkerPtr = CreateWorker<1U>(1U, shared_container);
        // start 3 threads
        producer_future: _ = std::async(:() = { (producer$)*.Run(); });
        first_consumer_future: _ = std::async(:() = { (first_consumer$)*.Run(); });
        second_consumer_future: _ = std::async(:() = { (second_consumer$)*.Run(); });
        std::cout << shared_container*.size() << std::endl;
    }
    // measure execution time and display the result
    end: const _ = std::chrono::high_resolution_clock::now();
    std::cout << "Time elapsed: " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms" << std::endl;
}

So accordingly to the list of checks that I’m interested in, CppFront gives us the following statistics:

  • length of the code – this code is 98 lines long (124 lines for C++)
  • build time – it takes 3600ms to build this application (2149ms for C++) with command:
./cppfront producer_consumer.cpp2 -im &&
g++ producer_consumer.cpp -I cpp_front_headers -O3 -DNDEBUG -march=native -std=c++23 -o producer_consumer
  • execution time – it takes 551ms to run it (597ms for C++)
  • binary size – 131kB (105kB for C++)

What I like about this code?

No standard includes

CppFront required me to write 0 include statements for the features that I’ve used. Zero. This is a huge improvement, especially given the fact the my test application doesn’t use any particularly advanced features and yet in C++ implementation I was forced to include something 11 times.

Private data members and public functions by default

At first it confused me, especially that there was supposed to be one syntax per language feature and yet in case of access specifiers if you don’t specify them, sometimes it means public and sometimes it means private. But after a second though I realized that this is actually how 95% of classes is designed. You have a private data, hidden from the external world and public functions allowing the external world to work on that private data, so in the end I consider it as a very interesting design decision.

Concise template declaration syntax

Whenever I’m typing a template declaration template <typename T> ... in C++, I wonder why is there so much characters needed if the only what actually matters is ...<...T>. CppFront does exactly that, so in order to declare a template class, you just put <T> next to it.

Impressive interoperability with C++

One of the CppFront’s goals is to preserve interoperability with C++ and I must say that sometimes it’s particularly impressive. Look for example at this line – a C++’s cv.wait function to which I’m passing a CppFront lambda function:

cv.wait(lock, :() -> bool = { return shared_container_*.size() > last_size_ || is_finished; });

Ability to embed variables in the strings

In C++, if I want to print a std::uint8_t id = 12U variable, I need to do one of the following things:

std::cout << "id = " << static_cast<unsigned>(id) << std::endl;
std::cout << "id = " << std::to_string(id) << std::endl;

what’s in my opinion a said joke. CppFront civilizes that finally and allows to embed the variable directly into the string which is being printed:

std::cout << "id = (id)$" << std::endl;

What I don’t like about this code?

Inconsistent variable declaration syntax

Although I tried, I couldn’t get static member variables declarations (like mtx, cv or is_finished) to work. Putting them into an anonymous namespace also didn’t do the trick. To my surprise, I finally managed to set them up using…an ordinary C++ syntax. This means that we can mix not only the C++ and CppFront features, but also the syntax of such basic operations like variable declaration.

Missing C++ features

CppFront says it’s 100% compatible with C++, but I wasn’t able to write a try/catch block because I was getting error:

invalid statement encountered inside a compound-statement (at 'try')

The same happened when I used throw.

Redundant base class initialization

I had to add line WokerInterface = () line to Producer‘s and Consumers constructor because otherwise I was getting an error saying:

in operator=, expected 'WorkerInterface = ...' initialization statement (because type scope object 'WorkerInterface' does not have a default initializer)

The problem here is that when I looked into the generated C++ code, WorkerInterface class has a default constructor generated, so I don’t understand why exactly is this needed in CppFront.

Required lambda return types

It’s a pity that providing a bool return type in lambda passed to cv.wait function is mandatory because even Cpp1 allows for not providing it.

Enumerations not available in templates

enum is generated as a type which can’t be used as a template function parameter in CreateWorker function as in C++ code. I was thinking if it’s more a bug or a feature, but in the end I decided to consider it as a downside because the ability to use enums in a templates arguments is a useful thing to have.

Obsolete const char* still a default

String literals in are still evaluated to const char* type. Someone may say “it’s obvious, what else they’re supposed to be evaluated to in C++?”, but if CppFront is meant to be an improvement in comparison to C++, I’d expect a different default string type evaluation.

To understand why it would be beneficial, let’s look at the following example: CppFront allows for discarding the types, so C++’s template argument deduction aligns very well with that concept. However, if you need an array of strings for a void(std::array<std::string, 2>) function, you have to write

arr: std::array<std::string, 2> = ("AB", "CD");

because the following:

arr: std::array = ("AB", "CD")

will be evaluated to std::array<const char*, 2> type. That’s how C++ behaves. CppFront could be better in that area.

Surprising requirements

After passing lock to the cv.wait function, I got a very strange error saying

static assertion failed: this function call syntax tries 'obj.func(...)', then 'func(obj,...);', but both failed - if this function call is passing a local variable that will be modified by the function, but that variable is never used again in the function so the new value is never used, that's likely the problem - if that's what you intended, add another line '_ = obj;' afterward to explicitly discard the new value of the object

As the message suggested, I put _ = lock after the cv.wait call and it solved the problem. It means that the error prompt is good because it allowed to quickly fix the problem, however it’s difficult to understand why such weird construction is even necessary. Maybe this have something to do with the CppFront’s rule that the return types can’t be discarded. Maybe it applies also by default to the variables whose state has been changed and never used again, but this seems like an unnecessary constraint – that variable could manage some external state, so such side effect wouldn’t be visible in its scope anyway.

Using CppFront to write C++ code for the existing code bases

Setting up CppFront for C++ code generation is straightforward because this is literally it’s main task. However, I don’t consider the C++ code generated by CppFront to be ready to use “as is” for committing to an existing C++ code base. The dependency to Cpp2 utils and usage of multiple Cpp2-specific macros makes it impossible to inject such code into an existing project maintained in Cpp1 without agreement on using Cpp2.

To be fair however, I must mention that most probably the C++ code generated out of CppFront was not even meant to be used this way, but because I’m searching in this series a tool which would be able to produce a C++ code for the existing C++ projects, I’m analyzing CppFront also from this perspective.

Using existing C++ code in CppFront

Because of full interoperability with C++, using the existing C++ code is also straightforward in CppFront. There’s even no need to have a library in form of a pre-built
binary because we can just include a header file into CppFront code and compile cpp_functionality.cpp file together with the application. This results in the following CppFront implementation of our reference C++ library user:

#include "cpp_functionality.h"

main: () = {
    p1: Point = (12, 24);
    p2: Point = (36, 48);
    adding_result: const int = Add(p1.x, p2.x);
    comparison_result: const bool = IsEqual(p1, p2);

    std::cout << "adding_result = (adding_result)$" << std::endl;
    std::cout << "comparison_result = (comparison_result)$" << std::endl;
}

It can be compiled using the following command:

./cppfront lib_user.cpp2 -im &&
g++ lib_user.cpp cpp_functionality.cpp -I cpp_front_headers -std=c++23 -o lib_user

Other interesting CppFront features

Vexing parse problem: gone

The vexing parse problem occurs when the compiler misinterprets a valid function declaration as something else, such as a variable declaration. In CppFront, this problem is entirely eliminated thanks to a more intuitive parsing process.

For example, in C++:

std::vector<int> v();  // Is this a function declaration or a variable?

CppFront avoids this issue with clear syntax distinctions, making it impossible to confuse variable declarations with function declarations.

Automatic move from last use

In CppFront, when an object is no longer needed (typically after the definite last use), CppFront automatically moves it, avoiding unnecessary copying and reducing potential performance overhead. Let’s take a look on the CppFront code which defines a simple function responsible for printing vector elements which is then called a couple of times on the same vector:

main: () = {
    data: std::vector = (1, 2, 3, 4, 5);

    print_vector(data);
    print_vector(data);
    print_vector(data);
}

print_vector: (input: std::vector) =
{
    for input do (element)
    {
        std::cout << element << " ";
    }
}

The above main function will be generated as:

auto main() -> int{
    std::vector data {1, 2, 3, 4, 5}; 

    print_vector(data);
    print_vector(data);
    print_vector(cpp2::move(data));
}

Notice the move in the last usage of data variable.

Error reporting with #line directives

Another clever thing in CppFront is the use of #line directives. These directives map error messages back to the original CppFront code, not the generated C++ code. This is supposed to make debugging significantly easier, as developers see error messages that correspond to the actual code they write. This is however true only if the bug is detected at the CppFront syntax level and not in the generate C++ syntax (more about that later in this article).

“Don’t care” wildcard

CppFront has “don’t care” wildcard _ which can be used to explicitly discard the returned values (which is nice) or to replace types in the variables declarations (which is less nice because either we enforce expliciit types or not – but that’s maybe because I was never a big fan of auto keyword in C++).

Mandatory initialization

CppFront ensures that every variable must be initialized before use, reducing the risk of undefined behavior from uninitialized variables. This is a significant improvement over C++, where uninitialized variables can lead to subtle and hard-to-track bugs.

Next: D as an alternative