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:
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:


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:
- A function can sometimes return a value. (eg. polling for some data)
- A stateful iterator function needs to mark when it is empty / completed.
- A lookup function fails to find a match. (eg.
hashmap.get()
)
I likewise imagine "error unions" in these scenarios:
- A function is expected to return, but can fail. (eg. memory allocation)
- A function requires certain specified input, and reports invalid inputs.
- 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.
macro
s:
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:
- C3 macros act similar to C macros; they can be evaluated at compile-time.
- Macros can look like functions, but use
macro
instead offn
. - Macros can't act in their caller's scope, so no declaring variables.
- Macros avoid the nasty
\
used for trailing macro lines in C. - Macro parameters can be prefixed with
$
, meaning they are compile-time known. -
Macro parameters can be prefixed with
#
, meaning the expression isn't yet evaluated. When a variable is passed to a macro this way, it is evaluated once, then used directly where the parameter is referenced. Pretty smart! -
User-defined macros which use
$
or#
parameters must be prefixed with@
. I like this - it means I can be confident whether a macro will screw up my AST. - C3 macros may have a variable number of parameters.
- Macros with only compile-time variables are completely evaluated at compile-time. This guarantee is solid. As far as I know, C doesn't ensure this itself. Yes, it will expand all of your macros, but the evaluation of the parts is up to the specific compiler you are using. As an extension to this, type reflection and compile-time execution in the language is handled exclusively via macros.
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:
-
alignof
- The standard alignment of the type in bytes. -
kindof
- The category of type, e.g.TypeKind.POINTER
/TypeKind.STRUCT
. -
extnameof
- Returns a string with the extern name of the type, rarely used. -
nameof
- Returns a string with the unqualified name of the type. -
qnameof
- Returns a string with the qualified name of the type. -
sizeof
- Returns the storage size of the type in bytes. -
typeid
- Returns a runtime typeid for the type. -
methodsof
- Retuns the methods implemented for a type. -
has_tagof(tagname)
- Returns true if the type has a particular tag. -
tagof(tagname)
- Retrieves the tag defined on the type. -
is_eq
- True if the type implements==
. -
is_ordered
- True if the type implements comparisons. -
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.