Cnx is a GNU C (11+) library providing type-safe collections and ergonomic features typical in higher-level languages, to C. It aims to be a proof-of-concept view of what a more modern standard (or at least widely used base) library could look like in the future if it only had to support C23, with much more modern abstractions and ergonomics over the standard C library. It is currently under active development and has not yet hit a stable release point.
Some features of Cnx include:
- Type-Safe collections implemented (mostly) as manually instantiated templates. Currently
implemented collections include
CnxString
,CnxVector(T)
, andCnxArray(T, N)
- Error handling facilities similar to Rust and
boost::outcome
with equivalent semantics and similar API to Rust, viaCnxError
andCnxResult(T)
- Optional value type,
CnxOption(T)
based on Rust'sstd::option::Option
with equivalent semantics and similar API to Rust - Facilities for defining, implementing, and using polymorphic interfaces via Traits
- Iterators, Ranges, and
foreach
loops (equivalent to C++'sfor(elem : collection)
) - Human-readable (no more having to remember which character combination corresonds to which type
in
printf
), type-safe string formatting and formatted I/O via theCnxFormat
Trait - More intuitive, more performant file API with
CnxFile
, along with simple file system interaction API withCnxPath
Cnx makes heavy use of __auto_type
and some of its features are only possible with it. It also
uses statement-expressions for several features and __attribute__(cleanup)
for scoped destruction.
It could be refactored to be Standard C in C23 or a subsequent standard, if C gets auto
, typeof
,
__VA_OPT__
, lamdas, and defer.
Can you? Sure! Should you? Probably not yet. Testing right now is minimal and only enough to ensure a majority of functionality works in the general case, and is definitely not thorough enough yet to ensure reliability in production.
You can view the documentation here
Cnx uses CMake, and incorporating it into your project is easy!
First, set up your CMake project. In CMakeLists.txt
:
FetchContent_Declare(Cnx
GIT_REPOSITORY "https://github.com/braxtons12/Cnx"
GIT_TAG "0.1.0"
)
FetchContent_MakeAvailable(Cnx)
### Setup your target......
target_link_libraries(your_target Cnx)
Then, include your desired headers, either the main header, Cnx/Cnx.h
, for everything, or
individual ones for granular imports.
#include <Cnx/CnxDef.h>
#define VECTOR_INCLUDE_DEFAULT_INSTANTIATIONS TRUE
#define RANGE_INCLUDE_DEFAULT_INSTANTIATIONS TRUE
#include <Cnx/Cnx.h>
void transform(i32* restrict elem) {
*elem *= 3;
}
i32 main(i32 argc, const_cstring* argv) {
// vector is already instantiated for builtins like `i32` and some provided types like `CnxString`,
// so, we can just use it directly here
let_mut vec = cnx_vector_new_with_capacity(i32, 10);
// insert 10 elements, 0 through 9, into the vector
ranged_for(i, 0, 10) {
cnx_vector_push_back(vec, i);
}
// print information about the vector (size, capacity, whether it is currently in
// small-size-optimization mode) to `stdout`, followed by a newline.
println("{}", as_format_t(CnxVector(i32), vec));
// print each element, followed by a newline, to `stdout`
// prints 0 through 9
foreach(elem, vec) {
println("{}", elem);
}
// transform the elements in the vector with the above-defined `transform` function
// and returns a view of the vector as a `CnxRange(i32)` (that we ignore)
cnx_transform(i32, vec, transform);
// print each element, followed by a newline, to `stdout`
// prints multiples of 3 from 0 through 27
foreach(elem, vec) {
println("{}", elem);
}
}
Cnx collections provide type-safety, iterators, the ability to use user-provided default and copy constructors, and destructors, for stored types, and, where applicable, are allocator aware. For more details on collections, see the documentation on the specific collection.
String formatting uses a very simple syntax. In a format string, {}
specifies that an argument
should be formatted and placed in that location. Format specifiers can be placed inside the braces
to specify the way the argument should be formatted. Currently supported specifiers are limited:
{}
: The default format{d}
: Format the associated argument as a decimal (base-10) number. This is the default for integral types. Only applicable to signed, unsigned, and floating point numeric types{dyy}
: Format a floating point number as a decimal (base-10) number, withyy
digits after the decimal point{e}
: Format a floating point number as scientific notation with the default number of significant figures (3) after the decimal point. This is the default for floating point types.{eyy}
: Format a floating point number as scientific notation withyy
number of significant figures after the decimal point.{x}
: Format an integral number as lower-case hexadecimal{X}
: Format an integral number as upper-case hexadecimal
Providing a specifier that is invalid for the associated argument will result in a runtime assert in debug builds and unspecified behavior in release builds (for builtin and provided types).
For more details on string formatting, see the documentation for the CnxFormat
Trait,
cnx_format(format_string, ...)
, and the [CnxFormat](@ref format) module.
I know, you're probably thinking "So, something that nice can't be fast right? What's the cost?" And the answer is: not necessarily anything. Generally, performance is faster than traditional standard C functionality, even with the increased ergonomics and composability gained from using the library's features.
We have two benchmarks to showcase this. The first pits println
against printf
from the standard
library. The second pits the Cnx file API against the standard library's fprintf
.
For the code used for each benchmark and more detailed results (Std. Dev., Median, individual runs),
see the project/code in the "benchmark" subfolder.
The implementation of println
makes heavy use of most of the functionality presented in the
example (except for CnxRange(T)
), as well as the Result(T)
type, which means it's using many of
the facilities currently provided by the library. Meanwhile, the file API uses everything println
does, in addition to providing a safer and more ergonomic API for file manipulation than the
standard library, while also taking better advantage of features like buffering to further improve
performance. So, lets start by taking a look at the benchmark comparing the
relative speed of println
to printf
.
This benchmark consisted of printing out N strings to stdout
, each consisting of:
- the Mth multiple of 1024, unsigned
- the Mth multiple of negative 1024
- the Mth multiple of negative 1024.1024
- a
CnxString
pre-initialized to "This is a string"
where M is in [0, N)
It was run 10 times for each N, with the average of the 10 runs taken. The benchmark was performed
with builds from both Clang 14.0.0 and GCC 11.2.0, and with both the default system allocator and
jemalloc.
All benchmarks were run on an Intel Core i7-8750H with 16GB RAM running EndeavorOS (Arch Linux) with
the Zen Kernel 5.17.5-zen1-1-zen.
Clang builds were compiled with "-flto -Ofast -ffast-math -DNDEBUG".
GCC builds were compiled with "-flto -ffat-lto-objects -Ofast -DNDEBUG"
All numbers are relative performance compared to printf
While these numbers are Linux specific, in general you can expect comparable numbers on Windows using native clang, and even better numbers using MinGW, but benchmark on your specific platform for platform specific numbers.
N | Clang + System Allocator | Clang + jemalloc | GCC + System Allocator | GCC + jemalloc |
---|---|---|---|---|
1 | 1.6316 | 1.9758 | 2.9815 | 2.1675 |
10 | 1.8762 | 1.4501 | 1.7318 | 1.6026 |
100 | 1.2305 | 1.2312 | 1.0868 | 1.0535 |
1000 | 1.1330 | 1.1666 | 1.0652 | 1.0045 |
10000 | 1.3016 | 1.0991 | 1.0266 | 1.0610 |
100000 | 1.1154 | 1.1374 | 1.0509 | 1.0517 |
average | 1.3814 | 1.3434 | 1.4905 | 1.3235 |
As you can see, on average you can expect around a 38% performance boost compared to
printf
. For infrequent I/O you can expect up to a 200% performance boost, and for high
frequency I/O you can expect around a 10% performance boost. If you dig into the detailed
benchmark results, you'll see that in general you can expect it to be fairly allocator insensitive
with clang (somewhat less so with GCC), and that building with clang and using the default system
allocator is the most consistently fast option to use.
So not only does using Cnx for string formatting and I/O give greatly improved ergonomics and composability over traditional methods, you also get a performance increase too!
Now for the benchmark pitting the file API against fprintf
. This is nearly identical to the
previous benchmark as fas as setup and methodologies go. The only difference is that the strings
are printed to two separate files, one for the file API and one for fprintf
, each using the
API being benchmarked for performing the string formatting and I/O.
N | Clang + System Allocator | Clang + jemalloc | GCC + System Allocator | GCC + jemalloc |
---|---|---|---|---|
1 | 5.2710 | 6.6535 | 3.6145 | 5.0965 |
10 | 3.6245 | 3.3975 | 3.2620 | 2.3175 |
100 | 2.4345 | 2.7620 | 1.6815 | 1.6745 |
1000 | 1.5145 | 1.5195 | 1.1050 | 1.1800 |
10000 | 1.3485 | 1.3350 | 0.9645 | 1.0255 |
100000 | 1.3335 | 1.3115 | 1.0365 | 1.0005 |
average | 2.5878 | 2.8298 | 1.9440 | 2.0491 |
So, on average you can expect around a 100% performance boost compared to fprintf
.
For infrequent I/O you can expect at least a 250% performance boost, or even higher, and for high
frequency I/O you can expect around a 17% performance boost. You can see that, particularly
at very sparse workloads, taking better advantage of buffering the way Cnx's file API does leads to
significant performance improvements over the standard functionality. If you dig into the detailed
benchmark results, you'll see that allocator choice plays a bigger role here than with println
,
in particular at very sparse workloads. Unforntunately, GCC begins to sputter out as the workload
increases, and in some cases actually manages to perform worse than the standard library functions.
However, the gcc + system allocator pairing does the most consistent performance
(lowest average standard deviation and smallest abs(averaged median - averaged average)). That said,
it's also the slowest pairing, and the clang + system allocator pairing comes in at a close second
in terms of consistency while still being the second fastest overall. Lastly, you'll see the
clang + jemalloc pairing is the most consistently fast option to use (highest average and
highest averaged median).
This was the performance for formatting builtin types (u32, i32, f32, cstring. CnxString
's
CnxFormat
implementation simply forwards itself, so it's about as expensive as passing a cstring
to printf
). That said, it would be reasonable to expect this performance to extend to printing for
custom types as well.
Tests are set up as a separate "Cnx-Test" target in the CMake project.
To run the tests, simply configure and build the test target, then run the resulting "Cnx-Test"
executable.
Please feel free to submit new tests!
Inside the Cnx main directory:
cmake -B build -G "Ninja"
cmake --build build --target Cnx-Test
./build/Cnx-Test
Feel free to submit issues, pull requests, etc.!
When contributing code, please adhead to the project .clang-tidy
, follow the project
.clang-format
(except in judicious cases of macros ruining things), prefer let
or let_mut
over
explicit typing (where possible), and prefer simplicity and correctness over performance by default.
Cnx uses the MIT license.
Special thanks should be given to the Rust team and C++ standardization committee, and contributors of each, for creating great programming languages that have inspired a lot of the functionality provided by this library. Thanks should also go to TotallyNotChase for inspiring the design of the Trait system and Iterators with c-iterators and to Hirrolot for also inspiring the Trait system with interface99.