2025-05-29 # Learning C3

In this article, I describe my experience learning the C3 programming language.

I've always been an avid computer programmer. I would say that I am most familiar with low level systems languages, though I have dipped into other programming languages from time to time. My journey here is motivated by curiosity. I am always curious to try new programming languages, which has resulted in learning a dozen or so within the past few years.

With each new programming language I learn, I encounter a never-before-seen paradigm. Each language has it's own method of expressing ideas, and it's own status-quo for solving problems. I hope to learn what this means for C3, and what projects I would be likely to use it for.

Here's the disclaimer: I'm writing this article in "real-time", which is to say I am typing it up as I am learning C3. This means that I may have added insights about pain points and neat features that get glossed over or forgotten in other articles, yet it may also mean that my explanations and conceptual grasp of the language is reduced or faulty compared to other sources.

Contents:

  1. What is C3?
  2. Language Overview
    1. Hello World
    2. foreach loops
    3. while loops
    4. enum and switch
    5. defer keyword
    6. struct types
    7. Error handling
    8. Contracts
    9. struct methods
    10. macros
    11. Type Properties
    12. Base64 & Hex Literals
    13. Primitives
    14. Much More
  3. First Steps
    1. Installing C3
    2. Creating a new project
  4. Making a calculator with C3
    1. What will this require?
    2. Getting user input
    3. The tokenizer
    4. The parser
  5. Conclusion

What is C3?

According to the C3 website, C3 aims to build on and with C. It offers ergonomics, optimizations, and features. Some of these features may be difficult or impossible to express in standard C. These features include a module system, operator overloading, generics, compile time execution, semantic macros, an integrated build system, error handling, defer, value methods, associated enum data, distinct types & subtypes, gradual contracts, built-in slices, foreach, dynamic calls & types, and more.

Here are two other perspectives on what C3 is:

C3 creator: 'C3 is an evolution on C' FoxKiana: 'awesome sauce' and 'epic'

Thank you to FoxKiana and the C3 creator Christoffer Lernö for replying :)

Language Overview:

It's hard for me to picture what the language is like without clear examples. For this section I will review the language reference online and share my thoughts on the language features C3 offers. I will not cover each and every feature of C3, as this isn't meant to be an introduction to C3.

Hello World:

import std::io;

fn void main()
{
    io::printn("Hello, World!");
}

Hello world seems pretty straightforward. I can immediately see this reminds me of C, although I personally prefer having the opening brackets on the same line as the function declaration. In any case, it's best to follow the status-quo here as it helps you understand other C3 code, and helps other C3 developers understand your code.

Something to note about importing modules - According to the modules page, imports will recursively import submodules. C of course would do the same, and you might end up needing to deal with name collisions by renaming things deep down the #include chain. C3 fixes the name collision issue by requiring you to use a more full case to resolve collisions. For example, you would use abc::Context instead of Context if both imported modules abc and de have a structure named Context.

I find it curious that the language requires the fn keyword to define the function here. I would think that the language could get away with avoiding that keyword like C does. On further thought, perhaps it is better to be more explicit that this is a function. Some reasons this may be is that the function declaration syntax could be used elsewhere, or C3 could allow nested function declarations. The first possibility seems more likely than the second, given that C3 is an evolution of C.

The standard print function will print most types that are passed to it. That seems pretty nice! Definitely a step-up from C here. I can imagine that print debugging works great in this language.

Taking a look at formatted printing, it seems that C3 resembles C quite a bit here. For the printf function, %s is still used for strings, %d is still used for decimals, %f is still used for floats. Truly C3 is easing the transition for C programmers. Something unique to note here - it seems that the string formatter (%s) can be used to format certain non-string types as well - so an enum of type Heat with a value of REALLY_WARM would format itself as REALLY_WARM when you use the %s formatter.

foreach loops:

// Prints the values in the slice.
fn void example_foreach(float[] values)
{
    foreach (index, value : values)
    {
        io::printfn("%d: %f", index, value);
    }
}

// Updates each value in the slice by multiplying it by 2.
fn void example_foreach_by_ref(float[] values)
{
    foreach (&value : values)
    {
        *value *= 2;
    }
}

Hold up, hold up. C3 has foreach? That is interesting. According to the docs, break and continue work as you would expect. Iteration by reference is accomplished by prepending a & (ampersand) to the variable name, as shown in the second example above. To me, foreach feels higher level than normal for. Don't misunderstand me - I love using foreach in other languages; the added syntax better expresses your intent, reducing logic errors. It did jump out at me as "this isn't C" though.

while loops:

// while loops are again the same as in C
int a = 10;
while (a > 0)
{
    a--;
}

// while loops can declare variables
while (Point* p = getPoint())
{
    // ..
}

Before C99, you had to declare the for loop variable outside of the for loop. As far as I know, you cannot declare variables inside of the while loop condition in C. It is nice to see the change here.

enum types and switch statements:

enum Height : uint
{
    LOW,
    MEDIUM,
    HIGH,
}

fn void demo_enum(Height h)
{
    switch (h)
    {
        case LOW:
        case MEDIUM:
            io::printn("Not high");
            // Implicit break.
        case HIGH:
            io::printn("High");
    }

    // This also works
    switch (h)
    {
        case LOW:
        case MEDIUM:
            io::printn("Not high");
            // Implicit break.
        case Height.HIGH:
            io::printn("High");
    }

    // Completely empty cases are not allowed
    switch (h)
    {
        case LOW:
            break; // Explicit break required, since switches can't be empty
        case MEDIUM:
            io::printn("Medium");
        case HIGH:
            break;
    }

    // special checking of switching on enum types
    switch (h)
    {
        case LOW:
        case MEDIUM:
        case HIGH:
            break;
        default:    // The compiler warns that all values are already handled
            break;
    }

    // Using "nextcase" will fallthrough to the next case statement,
    // and each case statement starts its own scope.
    switch (h)
    {
        case LOW:
            int a = 1;
            io::printn("A");
            nextcase;
        case MEDIUM:
            int a = 2;
            io::printn("B");
            nextcase;
        case HIGH:
            // a is not defined here
            io::printn("C");
    }
}

C3 resembles C a lot here. I appreciate the implicit break that it offers. I'll have to see when I install the compiler, but I feel I would frequently confuse myself here with these implicit / explicit break rules. Perhaps C3 would benefit from polarizing itself a bit more. It would make more sense to me if all breaks were implicit (requiring nextcase for all multi-case scopes), or explicit. That being said, my initial view will surely adapt to fit the language when I start writing it for myself.

After a short discussion in the C3 discord server, (specifically this message,) the power of nextcase was further emphasized:

int n = (count + 7) / 8;
switch (count % 8)
{
  case 0: *to++ = *from++; nextcase;
  case 7: *to++ = *from++; nextcase;
  case 6: *to++ = *from++; nextcase;
  case 5: *to++ = *from++; nextcase;
  case 4: *to++ = *from++; nextcase;
  case 3: *to++ = *from++; nextcase;
  case 2: *to++ = *from++; nextcase;
  case 1: *to++ = *from++; if (--n > 0) nextcase 0;
}

Yes, that is duff's device in C3! No ugly hacks here, the nextcase keyword supports a (possibly runtime) case, as if switch were a jump table. Additionally, the @jump attribute will force the optimizing compiler to turn the switch into a jump table, if it did not already do that. I'm pleased to note that C3 had this capability *before* Zig. I wish that more languages had this "jump table" view of switch statements.

defer keyword:

fn void test(int x)
{
    defer io::printn();
    defer io::print("A");
    if (x == 1) return;
    {
        defer io::print("B");
        if (x == 0) return;
    }
    io::print("!");
}

fn void main()
{
    test(1); // Prints "A"
    test(0); // Prints "BA"
    test(10); // Prints "B!A"
}

Ahh, the defer keyword. I've enjoyed this feature immensely in other languages. If you have never worked with defer before, then you are missing out! The feature allows for some amazing control flow capabilities. Essentially, all defer statements are invoked in reverse order on scope exit. They are primarily used to clean up resources that you have. I find the keyword is much easier to remember, as you always put a defer after claiming a resource that is only used in the current scope. Without the keyword, you are stuck remembering to clean up your resources at every possible scope exit point. In C, this frequently meant that there was a block at the end of the function with a goto label for cleaning up function resources. 10x better than C here.

On further investigation, it seems like the common constructs errdefer and okdefer can be represented with defer catch and defer try respectively. This will be pleasant to use.

struct types:

alias Callback = fn int(char c);

enum Status : int
{
    IDLE,
    BUSY,
    DONE,
}

struct MyData
{
    char* name;
    Callback open;
    Callback close;
    Status status;

    // named sub-structs (x.other.value)
    struct other
    {
        int value;
        int status;   // ok, no name clash with other status
    }

    // anonymous sub-structs (x.value)
    struct
    {
        int value;
        int status;   // error, name clash with other status in MyData
    }

    // anonymous union (x.person)
    union
    {
        Person* person;
        Company* company;
    }

    // named sub-unions (x.either.this)
    union either
    {
        int this;
        bool  or;
        char* that;
    }
}

After a minute or two, it seems more obvious how this works. It appears that the "sub structs" aren't types, but an expression for how the data is stored / accessed with the structure. The anonymous union inside of the structure is of particular note; it seems to me that they offer a really clean way of implenting a tagged union. You would only need to have an enum and anonymous union in your struct do do so!

C allows you to do essentially the same thing here, but I rarely used unions inside of my structures like this. Perhaps I need to brush up on my C.

Error handling:

The error handling page of the C3 website is very thorough. Below I have summarized what I have learned in a collection of C3 code snippets:

// Optional types are suffxed with a ?
int? a = 1; // Set the Optional to a result
// When an Optional is empty it has an Excuse explaining what happened.
int? b = io::FILE_NOT_FOUND?; // Set the Optional to an Excuse type of "fault"
// Faults are defined using the faultdef keyword
faultdef OOPS, LOTS_OF_OOPS, USER_ERROR;
// This function returns an Optional integer
fn int? get_value();
// Return an Excuse by adding '?' after the fault.
return io::FILE_NOT_FOUND?;
// Check if an Optional is empty with "catch"
if (catch excuse = get_value()) // ...
// If the scope is escaped after checking an Optional, the variable is
// automatically unwrapped for the remainder of the scope.
int? foo = unreliable_function();
if (catch excuse = foo) return excuse?; // Return excuse with the '?' operator
io::printfn("foo: %s", foo); // foo is guaranteed to be of type "int" here
int foo = maybe_function()!;
// The rethrow operator "!" above is equivalent to the following:
int? foo = maybe_function();
if (catch excuse = foo) return excuse?;
// Optionals in expressions produce optionals
int? first_optional = 7;
// "int?" is required for the type below, unless unwrapped (maybe implicitly)
int? second_optional = first_optional + 1;
// Optionals affect function return types
fn int test(int input) // ... omitted for brevity ...
int? optional_argument = 7;
int? returned_optional = test(optional_argument);
// The fuction is shortcut if any of it's non-Optional arguments can't be so

There's a lot to unpack here. It took me a while to understand how these optional types work. C3 seems to combine the roles of "error unions" and "optional types". In Zig this would be error{SomeError}!i32 and ?i32, while in Rust this would be Result<i32, SomeError> and Option<i32>.

This language decision will impact the way I think about error handling. I typically imagine "optional types" in these scenarios:

  1. A function can sometimes return a value. (eg. polling for some data)
  2. A stateful iterator function needs to mark when it is empty / completed.
  3. A lookup function fails to find a match. (eg. hashmap.get())

I likewise imagine "error unions" in these scenarios:

  1. A function is expected to return, but can fail. (eg. memory allocation)
  2. A function requires certain specified input, and reports invalid inputs.
  3. A property that is assumed to be true is false. (eg. runtime assertions)

With the roles combined, It seems harder to differentiate functions that are *expected* to return a value, versus functions that *can* return a value. While this is certainly a con, I would say that one pro of this approach is that you can avoid worrying about the distinction of the two split types when you are handling the return values of functions.

Contracts:

<*
 @require foo != null
 @ensure return > foo.x
*>
fn uint check_foo(Foo* foo)
{
    uint y = abs(foo.x) + 1;
    // If we had row: foo.x = 0, then this would be a runtime contract error.
    return y * abs(foo.x);
}

C3 has support for preconditions and postconditions. These contracts are kept inside of <* and *> symbols. According to the C3 website, this is a type of comment which is parsed. I have mixed feelings here. I personally think that a language only needs some well-placed (possibly runtime) asserts to handle inputs & outputs. As noted on the website, these contracts allow for more than just runtime assertions. C3 will propagate these conditions up the call chain, and analyze them during compile time folding. C3 does not currently perform static analysis *beyond* compile-time folding though.

It feels a bit strange to me that the language has this third method of comments, given that many programming languages have only one or two comment types. This is trivial though, I expect that I will easily adapt.

struct methods:

struct Foo
{
    int i;
}

fn void Foo.next(Foo* this)
{
    if (this) this.i++;
}

fn void test()
{
    Foo foo = { 2 };
    foo.next();
    foo.next();
    // Prints 4
    io::printfn("%d", foo.i);
}

Namespaced & dot-syntax functions are a thing, apparently. They work on unions, structs, and enums [EDIT: They work on *any* type, including primitives!]. This is another strict improvement over C, and I've enjoyed using them in other languages.

macros:

Macros are a bag of worms. Sure, they can be a great source of protein, but will you really see me eating them? I might use worms when I'm fishing, but I don't see much use for them around the home. To express my opinion outside of a metaphor: macros have niche use cases, are good at what they do, but shouldn't be abused. One example of this abuse would be making a turing-complete domain-specific language inside of some macro-supporting programming language.

I won't dive too far into the complexities of C3's macros, but here's a brief overview of what I learned:

Ok, that actually wasn't too bad. I guess I still have some stress from handling cursed macros in other language, such as Rust. Too much power and someone is bound to misuse it. C3 macros don't have quite the AST manipulation power of C, but they do have some really nice features in terms of compile-time evaluation. In my opinion, the macros in C3 are a good fit.

Type Properties:

C3 has built-in properties for all types. These properties are avaliable through .method syntax. Some types have their own specific properties, yet there are a baker's dozen that all types share. Here they are:

  1. alignof - The standard alignment of the type in bytes.
  2. kindof - The category of type, e.g. TypeKind.POINTER / TypeKind.STRUCT.
  3. extnameof - Returns a string with the extern name of the type, rarely used.
  4. nameof - Returns a string with the unqualified name of the type.
  5. qnameof - Returns a string with the qualified name of the type.
  6. sizeof - Returns the storage size of the type in bytes.
  7. typeid - Returns a runtime typeid for the type.
  8. methodsof - Retuns the methods implemented for a type.
  9. has_tagof(tagname) - Returns true if the type has a particular tag.
  10. tagof(tagname) - Retrieves the tag defined on the type.
  11. is_eq - True if the type implements ==.
  12. is_ordered - True if the type implements comparisons.
  13. is_substruct - True if the type has an inline member.

These properties appear to be very helpful for metaprogramming. In the Zig language, one would need to use @typeInfo on the type to get this information. I imagine that it would be harder for me to realize that type reflection is occuring in the code, however alluring the ease of access.

Base64 & Hex Literals:

C3 supports base64 and hex data literals. Personally I don't see the appeal, as the $embed macro would fulfill my common use-case for this. I imagine that a macro could accomplish the same thing as these types of literals. Honestly not too happy about this feature, I think it's trying to add language sugar for something that isn't often used.

char[*] hello_world_base64 = b64"SGVsbG8gV29ybGQh";
char[*] hello_world_hex = x"4865 6c6c 6f20 776f 726c 6421";

While not quite as bad as Rust, I do feel hesitant about the string situation overall in C3. I agree a lot with the C & Zig approach that "all strings are just bytes", rather than different syntaxes and types for representing the encoding or capability of a string type.

Primitives:

C3 has several primitive types. These include integers, booleans, and floats. The types are essentially equal to C, except char is defined to be unsigned. That's a big pro, considering C leaves char signedness up to the compiler. There are additionally 128 bit integers int128 and uint128, which are also pleasant to see. It's not often that you reach for a 128 bit integer, but when you do, you appreciate it.

C3 has the types iptr, uptr, isz, and usz. They are signed respectively according to the letter prefix, whether it be i or u. I feel these types are a bit shorter than they should be. The keystrokes that are saved by typing two or three fewer letters don't quite outweigh the cost of reading the code, at least for someone who is unfamiliar with the notation.

I do appreciate that the bit size of the other primitive types are well defined - it offers another improvement over C, where your int could be as small as 16 bits.

Much More:

The C3 language additionally has operator overloading (pretty great imo), struct subtyping (a bit confusing), generics (seems unclear, as the generic parameters are detatched from the type), runtime dynamic dispatch (no opinion), the any type (absolutely amazing idea, I love it!), distinct types (nitpick: why not use distinct instead of typedef?), bitstructs (awesome imo), and more (link about optionals).

Feel free to dive more into C3. For now, I'll continue.

First Steps:

Installing C3:

The C3 website provides helpful installing instructions for the compiler binary. Unfortunately for C3, not many people use it yet. I couldn't find any package for it on gentoo, so I'll probably build it from source.

Compiling the C3 compiler is surprisingly simple:

git clone https://github.com/c3lang/c3c.git
cd c3c
mkdir build
cd build
cmake ..
make

As long as I have LLVM and LLD installed it should work, right?

CMake Error: The following variables are used in this project, but they are set to NOTFOUND.
Please set them or make sure they are set and tested correctly in the CMake files:
LLD_COFF
    linked by target "c3c" in directory /home/retrodev/repos/C3/c3c
    linked by target "c3c_wrappers" in directory /home/retrodev/repos/C3/c3c
LLD_COMMON
    linked by target "c3c" in directory /home/retrodev/repos/C3/c3c
    linked by target "c3c_wrappers" in directory /home/retrodev/repos/C3/c3c
LLD_ELF
    linked by target "c3c" in directory /home/retrodev/repos/C3/c3c
    linked by target "c3c_wrappers" in directory /home/retrodev/repos/C3/c3c
LLD_MACHO
    linked by target "c3c" in directory /home/retrodev/repos/C3/c3c
    linked by target "c3c_wrappers" in directory /home/retrodev/repos/C3/c3c
LLD_MINGW
    linked by target "c3c" in directory /home/retrodev/repos/C3/c3c
    linked by target "c3c_wrappers" in directory /home/retrodev/repos/C3/c3c
LLD_WASM
    linked by target "c3c" in directory /home/retrodev/repos/C3/c3c
    linked by target "c3c_wrappers" in directory /home/retrodev/repos/C3/c3c

-- Generating done (0.0s)
CMake Generate step failed.

Haha nope. Let's check to make sure I actually have LLD installed:

*  llvm-core/lld
      Latest version available: 20.1.5
      Latest version installed: 20.1.5
      Size of files: 143789 KiB
      Homepage:      https://llvm.org/
      Description:   The LLVM linker (link editor)
      License:       Apache-2.0-with-LLVM-exceptions UoI-NCSA

Well that's not great! The build script detected LLVM just fine, but couldn't find LLD for some reason. I can probably use -DLLVM_DIR=... and -DLLD_DIR=... with cmake to ensure it knows where they are installed.

For a while I couldn't understand why cmake wasn't working here. And yes, the solutions just mentioned did nothing. Some time later I realized that my LLD installation did not include the LLD libraries which C3 relies on, so the problem is on my end. Instead of building LLD from source to get the required LLD libraries, I decided to just go the cheap route and download the C3 compiler build for my laptop:

retrodev@lime ~ $ c3c --version
c3c: /usr/lib64/libtinfo.so.6: no version information available (required by c3c)
C3 Compiler Version:       0.7.1
Installed directory:       /home/retrodev/repos/C3/c3/
Git Hash:                  c5494a23ce18ad16a382774a2f360c94b1515e3f
Backends:                  LLVM
LLVM version:              17.0.6
LLVM default target:       x86_64-pc-linux-gnu

Ahh, much better. Seems like the C3 compiler depends on libtinfo here; I'll probably install it at some point just to silence the error.

Creating a new project:

C3's compiler reminds me a lot of Zig's compiler. Both allow initializing projects (c3c init / zig init), building their respective projects (c3c build / zig build), and of course, compiling source files that don't belong to a project (c3c compile / zig build-exe). I'll use a project here.

retrodev@lime ~/repos/C3 $ c3c init hello_world
Project 'hello_world' created.

From the c3c init command, we can see that a directory for the project is created, including a lot of files that would otherwise be boilerplate. Both LICENSE and README.md are empty. project.json contains the project configuration, including source files, targets to build, optimization and target settings, and more.

retrodev@lime ~/repos/C3 $ tree hello_world -a
hello_world
├── LICENSE
├── README.md
├── build
│   └── .gitkeep
├── docs
│   └── .gitkeep
├── lib
│   └── .gitkeep
├── project.json
├── resources
│   └── .gitkeep
├── scripts
│   └── .gitkeep
├── src
│   ├── .gitkeep
│   └── main.c3
└── test
    └── .gitkeep

Yep. That's kinda what I was expecting. Overall I agree that the folder structure is helpful here - it establishes a status-quo for new users of C3. I do find it strange that C3 would create an empty LICENSE file though. I initially expected to find a general permissive licence, and was surprised when ls displayed the file as 0 bytes. That might have bitten me if I didn't notice.

Let's look at the generated main.c3 file:

module hello_world;
import std::io;

fn int main(String[] args)
{
    io::printn("Hello, World!");
    return 0;
}

Oh! this is pleasant. The conciceness reminds me of the default main.rs file from cargo new. I'd consider this an improvement on Zig, which by default will create a template stuffed to the brim with comments and example code. Zig's template is useful for beginners, but painfully verbose. I also hadn't realized that C3's String is title case. I'm not sure how I feel about that - it makes String feel more abstract than an int, which I suppose it is.

Let's compile and run this:

retrodev@lime ~/repos/C3/hello_world $ c3c run
Program linked to executable 'build/hello_world'.
Launching ./build/hello_world
Hello, World!
Program completed with exit code 0.

Fantastic. I think I'm ready to dive all-in.

Making a calculator with C3:

To better document this learning experience, I'll attempt to make a basic calculator in C3. I expect it to handle addition +, subtraction -, multiplication *, division /, negation (also -), exponentiation ^, and parenthesis ( ).

What will this require?

Writing this form of calculator will test my knowlege of C3. I will need to know how to make and call functions (especially for the recursive descent parser), how to get user input, how to do basic math with floats, and how to print to the terminal. I have a rough idea of how I want the code to look like, but the point here is to find and document what I find amazing - and not so amazing - about my experience learning C3. The internet is too full of armchair experts. Only my hands-on experience will provide me with an opinion worth sharing.

Getting user input:

As with any language, you trip when you transition from *reading* the language to actually *using* the language. In this case, I had neglected to read much of the "memory management" documentation for C3, so I needed to learn how allocators are currently used in C3:

module calculator;
import std::io;

fn int main()
{
    @pool()
    {
        io::printf("Enter an equation: ");
        String? equation = io::treadline();
        if (catch equation) return 1;
        io::printfn("Your equation: %s", equation);
    };
    return 0;
}

There are currently two default allocators used in code. (This isn't a link to the language reference, because it is updated by hand and this section is currently out of date.) These allocators are tmem and mem. tmem is a "temporary" allocator, acting as an arena allocator. The context for the temporary allocations is marked by the macro @pool. This macro will free items allocated with tmem at the end of the defined scope. Several functions in the C3 standard library are prefixed with "t", representing that they use the temporary allocator under the hood. These functions are alternatives to non-prefixed functions, which take an allocator parameter.

C3 feels like a balance of Zig and C in this instance. It is common practice in Zig to pass allocators to every function, so you can know what function may allocate memory, and pick the most effective allocator for the job. It is common in C to use only one allocator - namely the standard library allocator - through malloc, calloc, and free.

One more thing to note here are the return values of functions. Many C compilers will allow you to create the standard int main() function, then allow you to omit returning a value from it. The compilers I am talking about will implicitly return EXIT_SUCCESS (0 on all systems I know of) for you. C3 mandates returning a value from this function. It will give me an error if I fail to return a value:

 1: module calculator;
 2: import std::io;
 3: 
 4: fn int main()
           ^^^^
(/home/retrodev/repos/C3/calculator/src/main.c3:4:8) Error: Missing return statement at the end of the function.

C3 will also allow me to mark the main function as void, so this isn't bothersome. While it's not standard according to ISO C, some C compilers also allow you to define the main function as void.

Referencing the C3 code again, the io::printf and io::printfn functions return a value that the compiler does not require me to handle. C3 allows it's users to skip checking the return value here. This may introduce points of (admittedly very unlikely) failure into a program that are hard to identify, for the benefit of ease-of-programming. This is normal C behavior here. Also like in C, C3 will allow you to explicitly discard function return values by prefixing the function call with (void). EDIT: The functions here can be implicitly discarded because they have been annotated with @maydiscard. On the flip side, there is also @nodiscard.

The tokenizer:

So we are making a calculator? That's quite a bit to do. Let's take a bigger leap this time. Here's my code for tokenizing the input:

module calculator;
import std::io;
import std::collections::list;

enum TokenTag: char
{
    // Final Token
    DELIMITER,
    // Operators
    ADD,
    SUBTRACT,
    MULTIPLY,
    DIVIDE,
    POWER,
    // Operator precedence
    LEFT_PAREN,
    RIGHT_PAREN,
    // Number literal
    NUMBER,
}

struct Token
{
    TokenTag tag;
    float number;
}

faultdef INVALID_TOKEN; 

// Convert an input string into a list of tokens that define our equation
fn Token[]? tokenize(Allocator allocator, String equation)
{
    List {Token} token_list;
    token_list.tinit();
    defer token_list.free();
    
    // Scan through the string; most bytes should be one token.
    for (usz idx = 0; idx < equation.len; idx += 1)
    {
        switch (equation[idx])
        {
            case ' ': continue; // Skip whitespace
            case '+': token_list.push({.tag = ADD});
            case '-': token_list.push({.tag = SUBTRACT});
            case '*': token_list.push({.tag = MULTIPLY});
            case '/': token_list.push({.tag = DIVIDE});
            case '^': token_list.push({.tag = POWER});
            case '(': token_list.push({.tag = LEFT_PAREN});
            case ')': token_list.push({.tag = RIGHT_PAREN});
                
            default:
                // We probably have a number - parse it:
                usz start = idx;
                usz end = idx;

                // Scan digits and radix points from this position
                for (; end < equation.len; end += 1)
                {
                    char x = equation[end];
                    if (x != '.' && (x < '0' || x > '9')) break;
                }

                // This is true if we didn't parse a number
                if (start == end) return INVALID_TOKEN?;

                // Slicing indexes are inclusive on both ends
                float? number = equation[start..end - 1].to_float();
                if (catch number) return INVALID_TOKEN?;
                token_list.push({NUMBER, number});

                // Update the index so we don't re-parse the number
                idx = end - 1;
        }
    }

    token_list.push({DELIMITER, {}});
    return token_list.to_array(allocator);
}

fn int main()
{
    @pool()
    {
        io::printf("Enter an equation: ");
        String? equation = io::treadline();
        if (catch equation) return 1;

        Token[]? tokens = tokenize(tmem, equation);
        if (catch tokens) return 1;

        io::printn("[");
        foreach (Token t : tokens)
        {
            io::printf("    %s", t.tag);
            if (t.tag == NUMBER) io::printf(": %f", t.number);
            io::printn(",");
        }
        io::printn("]");
    };
    return 0;
}

Ok bear with me - This was a lot of code all at once. This code essentially gets the equation from the user, splits that into a list of "tokens" (parts of the equation), then prints out those tokens. Here is an example program output:

Enter an equation: 2+3 * (-7/-4) ^ 3.14
[
    NUMBER: 2.000000,
    ADD,
    NUMBER: 3.000000,
    MULTIPLY,
    LEFT_PAREN,
    SUBTRACT,
    NUMBER: 7.000000,
    DIVIDE,
    SUBTRACT,
    NUMBER: 4.000000,
    RIGHT_PAREN,
    POWER,
    NUMBER: 3.140000,
    DELIMITER,
]

This representation of the math equation will be perfect for the parser.

I'm sorry to say that I struggled quite a bit while writing this code. A lot of my struggles were not due to the language itself, but the lack of features that the language server offers. Code completion and jumping to definitions (in the standard library) just doesn't work. This should improve as the language matures.

One more confusing aspect of C3 is it's slicing. The syntax is of the form some_string[start..end], where both the start and end indexes are inclusive. This isn't consistent with most of the other programming languages I know, and it requires that I take the end index (exclusive) and subtract one for the slice. It also means I have to make sure I don't accidentally form a slice of negative length (the check above it). One other side effect of this language design choice is that I can't create a slice of length 0 this way. This confuses me. EDIT: C3 has built-in slice-by-length syntax of the form some_string[start:length], which is status-quo here.

To list the positives though, it is nice how convenient the List type is, which is supplied by C3's standard library. In C I normally use a prebuilt library of mine for this purpose, or I need to manually track my allocations, which isn't fun.

The familiar C for loops formed the meat of the tokenizer algorithm, and the foreach loop worked great for printing out the token types! In more complex tokenizers I may have been able to use the advanced nextcase statement (my tokenizers / parsers seem to turn into a FSM after a while). The control flow in this language is really nice to use.

There was one more thing I really didn't expect to find - the temporary allocator (tmem) could be used at the same time as the passed allocator for the tokenize function. This is interesting because you can use it for code that you need allocator assurances for. If I were to make this in Zig, I would either have to request a single allocator that worked both for the tokenization and return value, or request two allocators, and hope that the user would supply an efficient allocator for the intermediate process.

Side note: I really wouldn't use Rust for it's allocator flexibility - it wasn't designed to have any flexibility in this area, making it hard to use with custom allocators. C3 does very well here.

Let's finish this.

The parser:

Compared with the tokenizer, the parser was much easier to write. This is likely because I overcame the initial hurdle of writing code in the language. I'm pretty pleased with how simple it is! Here's the complete program (minus the tokenizer):

module calculator;
import std::io;
import std::math;
import std::collections::list;

// TokenTag, Token, and tokenize() trimmed for conciseness

faultdef UNEXPECTED_TOKEN;

struct Parser
{
    Token[] source;
    usz index;
}

fn float? Parser.parse(Parser* p)
{
    float result = p.expression()!;
    TokenTag next_tag = p.source[p.index].tag;
    if (next_tag != DELIMITER) return UNEXPECTED_TOKEN?;
    return result;
}

// <​expression> ::= <​term> (("+" | "-") <​term>)*
fn float? Parser.expression(Parser* p)
{
    float result = p.term()!;

    switch (p.source[p.index].tag)
    {
        case ADD:
            p.index += 1;
            result += p.term()!;
            nextcase p.source[p.index].tag;
        case SUBTRACT:
            p.index += 1;
            result -= p.term()!;
            nextcase p.source[p.index].tag;
        default:
            return result;
    }
}

// <​term> ::= <​factor> (("*" | "/") <​factor>)* | <​factor> <​term>
fn float? Parser.term(Parser* p)
{
    float result = p.factor()!;

    switch (p.source[p.index].tag)
    {
        case MULTIPLY:
            p.index += 1;
            result *= p.factor()!;
            nextcase p.source[p.index].tag;
        case DIVIDE:
            p.index += 1;
            result /= p.factor()!;
            nextcase p.source[p.index].tag;
        default:
            return result;
    }
}

// <​factor> ::= <​negation> ('^' <​factor>)*
fn float? Parser.factor(Parser* p)
{
    float result = p.negation()!;
    if (p.source[p.index].tag == POWER)
    {
        p.index += 1;
        float power = p.factor()!;
        result = math::pow(result, power);
    }
    return result;
}

// <​negation> ::= "-" <​negation> | <​number>
fn float? Parser.negation(Parser* p)
{
    if (p.source[p.index].tag == SUBTRACT)
    {
        p.index += 1;
        return -p.negation()!;
    }
    return p.number();
}

// <​number> ::= '(' <​expression> ')' | <​floating point number>
fn float? Parser.number(Parser* p)
{
    switch (p.source[p.index].tag)
    {
        case LEFT_PAREN:
            p.index += 1;
            float result = p.expression()!;
            TokenTag next_tag = p.source[p.index].tag;
            if (next_tag != RIGHT_PAREN) return UNEXPECTED_TOKEN?;
            p.index += 1;
            return result;
        default:
            if (p.source[p.index].tag != NUMBER) return UNEXPECTED_TOKEN?;
            float number = p.source[p.index].number;
            p.index += 1;
            return number;
    }
}

fn int main()
{
    while (true) 
    {
        @pool()
        {
            io::printf("Enter an equation: ");
            String? equation = io::treadline();
            if (catch equation) return 1;

            Token[]? tokens = tokenize(tmem, equation);
            if (catch tokens) return 2;

            Parser p = {tokens, 0};
            float? result = p.parse();
            if (catch result) return 3;

            io::printfn("Result: %f", result);
        };
    }
}

Let's take it for a spin.

Enter an equation: 2+3 * (-7/-4) ^ 3.14
Result: 19.388445

It works!

Earlier in this article, I expressed some concern about the error system. With this small example, I found that it worked out well. I was able to write the code nearly the same as if an error would never occur, trusting that it would get "bubbled-out" in the case that someone goofed up. 100% an improvement on C, yet again.

I also found that the nextcase keyword worked very well in both the expression and term functions. The same can be accomplished with a normal while loop with the switch, but this way it seems cleaner to me :)

Conclusion:

C3 reminds me of C. The flavor lingers in my mouth, yet I don't sense the same aftertaste. It isn't perfect though, I cut myself a few times. It is fun to work with. This language is simpler than C++ and Rust. This language is faster to develop in than Rust, C++, and Zig. This language is safer and more expressive than C.

Would I use this language daily? I don't think so. It is a good alternative to C, where projects that would otherwise be written in C could be written here. I think personally I prefer working with Zig, and that's alright. Zig holds a special place in my heart, and it won't give up easily.

C3 has loads of potential. Already I've seen this in my very short time using it. Yes, I will continue to use it. I haven't even tried to (ab)use the macro system yet, nor have messed with some of the more complex features that C3 has, such as it's dynamic interfaces.

The C3 source code is very readable. While in the making of this article, I was able to read the source code of many parts of the standard library with ease. Lots of languages with macros seem like navigating a non-euclidean labrynth, where the entrance and exit keep running away from you. The C3 macro system is well built, kindof feeling like a hybrid of Zig's comptime system and C's macros. I prefer it to other macro systems, hands down.

There are some things which I would rather not have in this language. These include inclusive slicing syntax, and the error system still doesn't quite sit right with me. Perhaps these will be fixed in the next update!

Thank you to is_human_ (1075947477913567294 on discord) for all the wonderful answers you supplied to me while I was trying this language out. Thank you to Christoffer for making this language, you are certainly more talented than I am: the compiler source code shows it.

If you are looking to try C3 out, go for it. Don't wait for it to become better. I've gained a lot out of this venture, and will continue to gain insight as I continue to learn it. Thanks for reading.