Problems of C, and how Zig addresses them
avestura.dev
avestura.dev
(PS: it's probably ok because C compilers seem to accept redundant typedefs)
The only sane way to deal with name collisions is either to use C stdlib types in library APIs (e.g. uint8_t), or use a library specific prefix (e.g. mylib_u8) - which is of course even more awkward than just using the standard uint8_t.
Naming might go well and have the same utility, but you never know what evil things are going on in all those headers, redefining each other's symbols and yours.
In what world would an u32_MYLIB would be different than an u32_SOMELIB?
Basic types are common enough.
Zig programmers don't need to keep talking about types, so the type (and thus in this case its size) is getting mentioned mostly when that matters, e.g. API boundaries.
1. After 5 minutes, you know what sizes they are, and don't need reminding.
2. Easier to touch type.
3. They're just aesthetically more pleasing to the eye.
4. The names aren't really different, they follow the most-used (by far) sizes on C.
5. It's easier to say and hear them. I can say "int" when talking code with someone, instead of "eye-thirty-two".
6. I'm guessing it may be easier for a visually impaired coder with a screen reader.
There is nothing wrong with that reasoning. Also, there will never be a language which is perfect in every conceivable way, this is such a minor difference that if someone chooses a language over this alone, they are not being reasonable.
> Besides, they're just easier to touch type.
These two styles are equally fine for touch typing… as long as you know how to touch type (not just how to touch type on the alphabet rows).
D allows 4 bit types using conventional bit fields (but you can't take a pointer to them).
const std = @import("std");
const expectEqual = std.testing.expectEqual;
test "u4 is 1 byte" {
try expectEqual(1, @sizeOf(u4));
}
test "u4 is 4 bits" {
try expectEqual(4, @bitSizeOf(u4));
}
test "u4 is 1-byte-aligned" {
try expectEqual(1, @alignOf(u4));
}
test "pointers to u4 work like any other pointer type" {
var foo: u4 = 10;
const foo_p = &foo;
try expectEqual(@as(u4, 10), foo_p.*);
foo_p.* = 7;
try expectEqual(@as(u4, 7), foo);
}
Packed structs are Zig's replacement for bit fields: const std = @import("std");
const expectEqual = std.testing.expectEqual;
test "bool has a bit size of 1 bit, a size of 1 byte, and an alignment of 1 byte" {
try expectEqual(1, @bitSizeOf(bool));
try expectEqual(1, @sizeOf(bool));
try expectEqual(1, @alignOf(bool));
}
test "in a regular struct, fields are aligned to their natural alignment..." {
const Natural = struct {
read: bool,
write: bool,
exec: bool,
};
try expectEqual(3, @sizeOf(Natural));
try expectEqual(1, @alignOf(Natural));
}
test "...unless otherwise specified" {
const TwoByteAligned = struct {
read: bool align(2),
write: bool align(2),
exec: bool align(2),
};
try expectEqual(6, @sizeOf(TwoByteAligned));
try expectEqual(2, @alignOf(TwoByteAligned));
}
test "in a packed struct, fields occupy exactly their bit size" {
const Packed = packed struct {
read: bool,
write: bool,
exec: bool,
};
try expectEqual(3, @bitSizeOf(Packed));
try expectEqual(1, @sizeOf(Packed));
try expectEqual(1, @alignOf(Packed));
}Now maybe if you're already 64bit that's fine (it's unlikely that we'll ever need 128bit, and code is unlikely to grow down), but for anything starting smaller it's a pain.
Zig, like Rust, has two kinds of "primitive" numeric types, the kind which are an explicit size in bits (u8, i16, f64 and so on) and then the word size ones (isize, usize), which are whatever size is suitable for your target machine.
C gets this all muddled because it has named primitive numeric types but their meaning is imprecise, and then it uses a typedef to assign one of these (but you don't know which one) as the native word size. So maybe long is the same as your size_t, and C will just assume you know what you're doing when you write a long where you need a size_t - thus making your code non-portable.
Also minor nitpick I have is that the interval/range syntax in zig is pretty confusing as it can be both inclusive and exclusive depending on context. Swift does it imo better by ... being inclusive and ..< exclusive
Actually, I don't even want operator overloading in general (leading to stuff like the C++ stream API), it's JUST for vector stuff (ideally with swizzles); same for all the shading languages.
Perhaps it's doable to make a little domain-specific language (DSL) in Zig for this purpose?
With vector extensions (and as floh [hi!] points out, also matrices please) I'm 100% ready to seriously consider transitioning from C++, given the smooth C integration.
vec4 c = a * 4 + b;
instead of vec4 c = vec4_add(vec4_mul(a, 4), b);
i.e. infix vs postfix order, with simple * and +/- operators etc. I would very much like to appeal to the Zig authors to see the beauty in the former expression compared to the latter, for a huge class of real-world mathematical applications.vec4 c = a * 4 + b;
vec4 c = vec4_add(vec4_mul(a, 4), b);
vec4 c = vec4_fma(4, a, b);
Another possible route is to require that all types contained within the exported types are also numerical, or have some specific set of operators defined.
Also, you mentioned matrices in the previous comment, but multiplication between matrices is not commutative.
New types also don't have to inherit the properties of the operators they use.
In any case, my point was that there are multiple avenues to explore in providing operators in a way that don't compromise the compiler's ability to optimize numerical code.
- hidden control flow (function calls should look like function calls, an operation should never mask a function call)
- global weirdness. If a library changes the language, how does that affect some other code you're pulling in? Where do you effect those changes?
https://godbolt.org/z/ccYPaz1Pj
For this example (AVR), this is the int multiply function __mulhi3:
00: 00 24 eor r0, r0
02: 55 27 eor r21, r21
04: 00 c0 rjmp .+0
06: 08 0e add r0, r24
08: 59 1f adc r21, r25
0a: 88 0f add r24, r24
0c: 99 1f adc r25, r25
0e: 00 97 sbiw r24, 0x00
10: 01 f0 breq .+0
12: 76 95 lsr r23
14: 67 95 ror r22
16: 00 f0 brcs .+0
18: 71 05 cpc r23, r1
1a: 01 f4 brne .+0
1c: 80 2d mov r24, r0
1e: 95 2f mov r25, r21
20: 08 95 retAn operator is either a function call or some simple built-in operation.
Thus not hidden.
That syntactic elements that consist of symbols rather than alnum and that are used as prefix or infix operators are necessarily “not a function call” is just a rule that can be changed.
In theory, yes. In practice, an operator is a simple built-in operation 99.999% of the time, which lures programmers into thinking that's how it always is.
It's like a self-driving car that's safe 99.999% of the time but still requires you to continuously pay attention to take over at a second's notice.
Humans just don't work like that.
Noo it isn’t. Many languages use something like `+` for string concatenation. Maybe list concatenation.
You can restrict operator definitions to expressions containing other operators. Operators are now guaranteed to expose only as much control flow as the underlying operators would already expose.
> - global weirdness. If a library changes the language, how does that affect some other code you're pulling in? Where do you effect those changes?
I'm not sure what you mean. Behaviour depends on language semantics. You don't specify why operators are uniquely weird on this compared to literally any other function call when language semantics or library behaviour changes.
Having to define these operators as functions with unique text names is extremely ugly and serves no purpose that I can see.
Something like this could actually fulfill Andrews goal of not having any hidden control flow, as the function would be plainly visible.
The other thing of course would be, what then the valid function names would be? Definitely not `*` but probably `×` or whatever.
Haskell.
const @"..." = @import("my_operator.zig").add;
For example. Then in the code the parser transforms (a ... b)
to @"..."(a, b)In any case, if something practical is done here I'd be interested. Aesthetically I prefer the "vectors and matrices are first class types" (and maybe complex numbers could be too), but I guess Andrew isn't so sympathetic to these types being part of the standard language :/
Vectors are already first-class types via @Vector, aren't they?
I'll hope for some future news of Zig having vector and complex number support! It will be a great day for fast ray/path tracers :)
Edit: I asked in the Zig Discord and was told "it's been denied many times", oh well :/ Fair enough, it's their language to control.
It ends up slightly more verbose in usage, but the statements themselves remain concise. Unfortunately I got a real job that isn't using Zig, so I've stopped working on this. Others can feel free to take up the torch, though.
It would be nice, for sure, to be able to define some infix combinators.
Anyway, it’s bad enough that arithmetic has pressured almost all programming languages to adopt operator precedence. (No operator precedence other than either go-left or go-right is so much simpler. Which would also mitigate some of the complaining about custom operators since then they just become infix functions.)
I think the major problem here is shoehorning everything into a “symbol soup” on a 2D matrix. I do get that it has plenty advantages (can be typed without special program, easier version control, etc), but I would like to see a resurrection of interest in visual language. I’m not meaning something as visual as scratch or so, but some special blocks could come in handy.
I imagine if I had more experience in Zig, none of that would be a problem, and its not aiming to be a beginner-friendly language anyway, but that was just my experience of it so far
Without getting into the weeds of why (happy to do so, just want to keep this readable), basically I needed to define and populate a hashmap in a new script and then import it into my main script, which to my mind left me with two options:
* Define and initialise it at the same time (my preferred method) as a constant. I don't have much to say on this as iirc, I had no luck with it at all, never even got close.
* Define it in a (public) function, add each field in with "put" and then return the hashmap. I tried with std.AutoHashMap and various other things, but to what I could work out there was no type of hashmap, so it wouldn't accept my return type.
fn buildMap(allocator: std.mem.Allocator) !std.AutoHashMap(u64, u64) {
var result = std.AutoHashMap(u64, u64).init(allocator);
errdefer result.deinit();
try result.put(10, 100);
try result.put(20, 200);
return result;
}
Your #1 option should be possible once Zig has comptime allocators -- it's on the roadmap, but not possible yet iirc.Cheers for letting me know though!
const mymap = {1: "hello", 2: "world"};
But the only thing I could find any answers for was something more like: const mymap; mymap.put(1, "hello"); mymap.put(2, "world");Zig hashmaps _are_ quite a bit more cumbersome than, eg, in Rust --- you need to decided whether you want the allocator to be bundled with the hasmap, and its also up to the user of the hash map to provide equality and hash code (and, of course, there's manual defer instead of RAII).
However, this flexibility and verbosity comes with a super-power --- you can pass an allocator to a hash map at creation time, and then _not_ pass an allocator when you actually use the hash map. This is huge. This means that we get compile-time guarantee that we follow rule 3 of NASA's 10 rules
3. Do not use dynamic memory allocation after initialization.
And we _still_ can enjoy using a hashmap from the standard library! No other language I know has an equivalent tool.In Zig, the map could be initialized and sized at runtime, but you still can enforce, statically, that it doesn't do any allocations after that.
In both nightly Rust and Zig, heapless version can be expressed by passing a fixed-buffer-backed allocator to the standard hash map.
https://github.com/ziglang/zig/blob/0dffab7356685c7643aa6e3c...
https://github.com/ziglang/zig/blob/0dffab7356685c7643aa6e3c...
One of them requires passing an allocator (and can allocate), the other doesn’t have an allocator argument (and thus can’t allocate). If you only pass allocator to `init` method of your application, and don’t store it anywhere, only init will be able to call allocating methods, the rest of the app will be allocation free, by construction.
I can see that the difference between the two is in self.growIfNeeded() call, https://github.com/ziglang/zig/blob/0dffab7356685c7643aa6e3c..., which the one that doesn't allocate really doesn't have. Does it assume that the predefined capacity will not be reached?
Yes, the function name says exactly that as well, though admittedly what this actually means and what consequences it might have if the assumption is false are probably fairly opaque to a novice user.
> how does it handle the case where you don't have enough capacity to insert the new (k,v) pair?
Breaking the invariant results in safety-checked undefined behavior, that's what the "asserts" in the doc comment signifies[0]. Basically, if something goes awry at runtime you'll get a crash along with a nice error trace in Debug/ReleaseSafe mode or with the appropriate @setRuntimeSafety call.
If instead you'd like to have errors that you can handle, you could use your real allocator where you expect to actually use it and pass a failing allocator[1] everywhere else (but that's sort of abusing the API IMO, I don't know if I'd actually recommend you do this).
[0] https://ziglang.org/documentation/master/#Doc-Comment-Guidan...
[1] https://github.com/ziglang/zig/blob/0dffab7356685c7643aa6e3c...
This also means that there is nothing novel about this approach that cannot be achieved in other programming languages as one of the parent comments claimed.
This is simply a hashmap with pre-allocated pool of memory.
I completely agree: this has been my experience as well.
That might be true for the standard library, but it is definitely possible for a function to use an allocator from a struct or a global. Not to mention calling a c function that allocates.
> Safety tools to avoid memory leaks e.g. std.heap.GeneralPurposeAllocator
Maybe I'm missing something, but AFAICT, that doesn't prevent memory leaks, it just has the ability to log if there were leaks. Like a built-in valgrind.
> [Zig] helps your remain safe and avoid leaks
So it talks a little about how to _identify_ memory leaks, at runtime. Which is also possible in C with tools like valgrind. But it doesn't mention defer, which is an advantage zig has over c (at least portable c) for memory management. And it doesn't talk about the "safe" part at all, which I would take to mean protections against use-after-free, double-free, unitialized variables, invalid free, etc.
“C is actually assembly” was quite a plot twist from there.
Assembly languages actually haven't changed all that much since the 70's, but compilers have improved a lot. The output of early C compilers did indeed match the source code quite closely (and not just on the PDP-11), but even today that's true if you disable optimizations (and even with optimizations is usually pretty straightforward to map the C source to the assembly listing - if you're somewhat aware what optimizer passes in modern compilers are doing).
Of course CPU ISAs are already human-friendly abstractions over what's actually happening down in the hardware.
I can't name another language for which this is true.
I get that you're hinting at the insane level of undefined behaviour enforcement by compilers, but I don't think it matters all that much once you understand how optimizing compilers work.
I mean, can you though? After all you won't even have the same output depending on compiler and flags. And of course not all architectures have the same capabilities, so the same code can compile to a various number of instructions depending on the target architecture.
Not to mention things like bitfield access that can result in non-atomic load/stores for a simple `foo->bar = 1;`
I'm not sure in what sense you could say that C operations map 1-to-1 with assembly any more than Rust, C++ or basically any compiled language.
int test(int x, int y) {
return x % y;
}
test: // @test
sdiv w8, w0, w1
msub w0, w8, w1, w0
ret
Surprisingly, assembly doesn't have the remainder instruction, but instead it has a multiply-then-subtract instruction which is not corresponding 1-to-1 to anything in C. Description: The 32-bit two's complement integer in R and Rv1 is divided
by the source operand. The quotient is left in R; the remain-
der in Rv1. Division will be performed so that the remainder
is of the same sign as the dividend. R must be even.
Example: CLR RO
MOV #20001,R1
DIV #2, RO
Before After
(RO) = 000000 (RO) = 010000 Quotient
(R1) = 020001 (R1) = 000001 Remainder
PDP-11 also had ADC and SBC (add/subtract with carry) instructions which weren't (and still aren't) exposed in C either.The code in the comment you replied to is 64-bit ARM assembly.
It does not. Implicit casting, inlining, volatile, args passed via registers vs stack etc can significantly change what you expect to be generated.
This is by design, because C was created do deal with disparate processors and OSs. When it was crated it would be difficult and unwise to assume that an integer has size of 2 bytes, for example, since each machine would define its own preferred length.
It was still a bad design. Of course this is in hindsight, but history has taught is it would have been much better if the default was for specific sized integers, with an option to use `uint_fast32_t` or whatever.
But it wasn't, C made all these software tools possible.
English is very popular but you wouldn't say "English is what made all those books possible" would you?
> what language should better earn the title?
None.
(and I'd argue that C is closer to assembly than assembly is to what's actually happening inside the CPU, e.g. assembly itself is a high-level abstraction layer that's still pretty close to C - which isn't all that surprising because both probably developed as a symbiosis over time - especially when you look at all the non-standard language extensions in various C compilers)
And no, it's just not usefully true to model things this way. The C abstract machine is pretty weird even compared to a PDP-11, and your modern computer is nothing like a PDP-11.
C was intended to be efficiently implementable, so that's nice, but it has numerous defects in practice in this regard, because it pre-dates a lot of discoveries about how to implement programming languages.
The machine doesn't have types. At all. They're just not a thing. C has types. They're not very good types, and they're poorly implemented, but they are definitely types. Several other languages from that era don't bother, C does because it's a "high level language" and you'll do better embracing that understanding than trying to pretend it's assembler.
If you want both, but don't want a Rust-style borrow checker (with all the restrictions this entails), use 'tagged-index-handles', those work just fine across all languages (and even make sense in Rust):
https://floooh.github.io/2018/06/17/handles-vs-pointers.html
It is hardly safer than Modula-2 already was over C already in 1978.
We are in 2023 now.
Use-after-free is a well known problem in the memory managed programming space and one that Zig infamously does not tackle.
For those who aren’t familiar, it’s the use of a resource (a pointer) after it is no longer available for use (or has been freed). Which can in turn result in accidentally accessing unexpected data or crashing.
Like with all languages, if you’re leaving default safety up to programmer convention, the programmer will let you down eventually. Rust, swift and any language where raw pointers are the exception not the rule (any GC, ref counted or borrowing language really), they all switch the defaults around so that you’re not dealing with memory management yourself unless you absolutely want to.
Another thing that caught me by surprise is passing arrays as references. Ages ago we used to pass arrays (`int arr[100]`) as pointers (`int*`). I'm sure C still supports this?
Are people just really excited about this project? Are the people behind it just really good at marketing?
Honest question: why do we all keep talking about Zig?
Seems totally insane to me to use a pre-1.0 application written in a pre-1.0 language in production considering Node and Deno basically do the same thing.
I'd be very happy to hear I am wrong about this and see a profitable company with an established product using it.
Is there a problem with this? If you have a problem to solve and nobody's telling you you have to use certain tools to solve it, why not try a new tool yourself and see if it solves it better than the old ones? I do this whenever I can on client projects and I've found some rather pleasant tools this way.
As for who's using Bun in production, I don't know. But it's been around for a while and seems to have decent buzz around it in the JS community when observed from a safe distance (the proper way to observe the JS community), so I assume someone is. Take that for what it's worth.
- Uber uses Zig to produce hermetic builds of their backends and was able to move their C/C++ codebases to arm64 thanks to Zig's C/C++ cross-compilation support.
https://www.uber.com/en-US/blog/bootstrapping-ubers-infrastr...
- Bun is written in Zig and its sudden success was big enough to cause Deno to have an identity crisis.
- TigerBeetle is written in Zig and it's probably the most promising upcoming database company out there.
- There are a few more notable examples, but I haven't been given permission to talk about them publicly yet.
> Honest question: why do we all keep talking about Zig? > Are the people behind it just really good at marketing?
The project is simply inherently interesting. For one reason or another, nobody figured out a proper build & cross-compilation experience for C/C++ before Zig did it, just like nobody has figured out a good package manager yet for C/C++ and we're about to do it.
When Apple Silicon came out Zig was the first compiler able to cross-compile for it (way before LLVM btw) thanks to our custom linker, and thanks to it and the fact that we're working on our own custom backends, we're also going to achieve incremental compilation with in-place binary patching, ultimately reducing linking time to basically zero for incremental rebuilds.
Zig is also the only language that has async/await but that doesn't have an ugly & wasteful split between blocking and evented versions of the same networking library.
I'm stopping here with technical arguments, but there would be more to talk about.
Lastly and most importantly, the project has an interesting approach to finances and governance: we've had a non-profit foundation for longer than Rust and we have made a point to never, ever, let big tech companies influence the governance of Zig.
The Zig Software Foundation pays its developers (instead of hiring copywriters & marketing people) and 90+% of what we make through donations or support contracts goes to developers. The rest is infrastructure and administrative costs (eg CI, accountant).
I'm the guy who's most in charge of marketing and I'm currently working on the automated documentation system because Zig pretty much markets itself.
VP of Community strikes again.
But they are boring; nerds like shiny new objects.
We do embedded development for medical devices, and zig fits perfectly because we can use static allocators and have all the features of the language.
It works very well, we write firmware for STM32 serie of chips.
We can compile the same code to WASM and run it into a simulator, it works great for us.
The one most people jump to from the past few years is Rust and/or Go, but we've experienced this with other languages like Lisp (& variants), Haskell, Scala had a small window, etc. Zig will no doubt have its own moment like this (if it's not already), and then the hype cycle will settle into wherever Zig is used best.
Think of it like a magnifying lens on overall programmer sentiment/interest.
I've been falling in love with C++'s std::experimental::is_detected and am looking forward to being allowed to use C++20 in my environment since it will bring Concepts, but in my cursory examination it seems Zig seems to prefer vtable abstractions like Allocator.VTable.
[1] https://github.com/ratfactor/ziglings/blob/main/exercises/09...
The thing about Go interfaces (and C++20 Concepts) is that you can name a type that contains certain methods or behaves a certain way, but you don't actually have to inherit from the interface or concept explicitly - anything shaped correctly that conforms to the interface or concept will work.
And at least with Go, if you try and pass something in that doesn't conform to the interface, it is very particular about telling you what you're missing. One downside of C++ templates is that if you have a problem with what you're passing in, you might get a horrendous error message somewhere deep in the implementation - or worse, your code might compile just fine, but have unexpected behavior - instead of a nice "Hey, you need to add a method named `foo` to this type."
var arr = [_]u32{ 1, 2, 3, 4, 5, 6 }; // 1, 2, 3, 4, 5, 6
const slice1 = arr[1..5]; // 2, 3, 4, 5
const slice2 = slice1[1..3];
If I were to use indices out of range, presumably that would that give me a compile time error? Could I use variables as the indices and, if so, could I get a seg fault at runtime or are there some sort of guards on there?Yes, if all operands are comptime-known (or comptime-length-known), bounds checks happen eagerly at compile time.
> Could I use variables as the indices and, if so, could I get a seg fault at runtime or are there some sort of guards on there?
Yes you can use variables. If they're out of range, behavior depends on the build mode:
- In Debug and ReleaseSafe, you get a guaranteed panic (which aborts the app with a stack trace)
- In ReleaseSmall and ReleaseFast, you get undefined behavior
On their discord I suggested a version of slicing that bounds checks in all modes (something like `slice?[1..5]`, syntax doesn't matter) and returns an optional slice, which is null if the bounds are out of range. But they didn't seem too keen on it. So I've been using a little wrapper function instead.
fn foo(x: i32) i32 {
return x + 100;
}
fn bar(x: i32) i32 {
@setRuntimeSafety(false);
return foo(x + 100);
}
Particularly when you have arbitrarily deep call graphs where some explicitly enable and disable runtime safety.Unless you build in releasefast, which disables that check. There is releasesafe for a release build that keeps bounds checking panics.
yup
$ zig test test.zig
test.zig:8:56: error: index 3 outside array of length 2
std.debug.print("\ncompile error: {d}\n", .{slice2[3]});
> Could I use variables as the indices and, if so, could I get a seg fault at runtime or are there some sort of guards on there?Bounds are checked by default:
$ zig test test.zig
test.zig:9:56: error: index 3 outside array of length 2
std.debug.print("\ncompile error: {d}\n", .{slice2[invalid_index]});
But you can disable them by building in `ReleaseFast` mode[1]Here's a test file you can use to play around with it if you like:
const std = @import("std");
test "bounds" {
var arr = [_]u32{ 1, 2, 3, 4, 5, 6 }; // 1, 2, 3, 4, 5, 6
const slice1 = arr[1..5]; // 2, 3, 4, 5
const slice2 = slice1[1..3]; // 3, 4
const invalid_index: usize = 3;
std.debug.print("\nslice2: {d} {d}\n", .{ slice2[0], slice2[1] });
std.debug.print("\ncompile error: {d}\n", .{slice2[invalid_index]});
try std.testing.expect(slice2.len == 2);
}
1: https://ziglang.org/documentation/master/#Build-ModeYou can disagree with the language principles if that is one’s position. However, the ‘resistance’ to RAII is a clear effect of those principles.
Don't try to like Zig and instead make sure your cup is empty before entering a new tea shop.
const result = comptime square("hello"); // compile time error: type mismatch
ok, cool. But if the error occurs deep in some hierarchy of comptime calls, do you get the same kind of long errors that you do with C++ templates? Does zig have a way of achieving better ergonomics? One nice thing about generics in Rust and Swift is they are constrained by traits/interfaces so you get a concise error at the call site. /**
* @checked x & 1 "The input parameter must allow bit operations"
*/
macro bool is_power_of_2(x)
{
return x != 0 && (x & (x - 1)) == 0;
}
In the example above (disregard the fact that it's placed in the doc comments) we'd get an error if `x & 1` is invalid (doesn't pass semantic checking). This is the simplest possible example. Basically if you pass in a float or a struct you would then get "The input parameter must allow bit operations" rather than a dump of errors from the macro body.We can imagine other constraints, such as checking the value (if constant) to conform with valid ranges and so on.
This is more of a "contract" style constraint that can be placed directly at the call location, rather than pushing down the check further down into macro body, for example something like this:
macro bool is_power_of_2(x)
{
$assert($checks(x & 1), "The input parameter must allow bit operations");
return x != 0 && (x & (x - 1)) == 0;
}
In this example the error would be localized to the macro, which perhaps isn't what we want, even if the compiler is kind enough to tell us where the macro was included.The only reason I thought about it is because I have been trying (and failing) to contribute to open source Linux projects - which are practically all written in C and the C++ projects are impossible to read.
The C# specification isn't significantly smaller than the C++ one (can't check the exact length right now) but the language is still much easier to understand, even if it has really hairy corner-cases like the difference between readonly fields and normal fields' generated IL or the logic for defensive copies. Most programmers don't have to care, and even if you do, it's not a nightmare to test or experiment with.
In C++, I kind of feel I'm trying to squeeze water out of a stone. And even with the huge standard library, basic things are missing from it like a string startswith.
It was added in C++20: https://en.cppreference.com/w/cpp/string/basic_string/starts...
The issues I faced with C++ were that it has too many features. I feel that makes it difficult to carry experience in one project to another due to the variety in styles afforded by that design choice.
I once saw a C++ tutorial that rewrote a verbose for loop into a functional chain and it was practically unreadable for anyone lacking considerable C++ knowledge.
I'm also not a big fan of the way the module system in C++ works (similarly to C#, Java, Rust) where you import a namespace and then things are just available or extended.
Probably an unpopular opinion but, when evaluating the language semantics alone, I quite like the approach TypeScript takes to modules. You import a "thing" from a "relative filepath" explicitly and there is no ambiguity as to its origin (even when just groking a file in a low-tech text editor). For me it makes the process of tracing and understanding circuits easier than sorta guessing which namespace a function or method comes from. This also makes it easier for compilers to optimise binaries as they can statically determine what code is used, excluding unused code from a build.
I guess I could probably say the build system for C is difficult to grasp. In the high level world, I'm used to simply saying "compiler build main.xyz" - where C has makefiles, configure scripts and I find it a bit much.
But what do I know, haha
Because the "footgun" here is really that when using macros we are switching languages and strategies - in one case it's eager evaluation, and in the other is sort of like lazy evaluation.
This is sensible since you want changes in, e.g. your code tree to taint compiled resources. If your code can go read from the filesystem in an untracked way, you break the incremental compilation model.
Putting it at the callsite is more explicit actually and for example generating precompiled arrays clearly ties comptimeness to the actual artifact created instead of making it inferred from the signature of the function (which may be very far away in code)
Notice how the add() function is completely 'disolved' into its result '5' here:
https://www.godbolt.org/z/q98svvacW
(the main difference to comptime is that this guarantees that the code is resolved at compile time, and if that's not possible you'll get an error).
The big downside of comptime is that it isn't debuggable, apart from what's essentially 'printf debugging' via the @compileError builtin).
So far, it seems that the only way to handle exceptions is to use another programming language. Zig allows you to mix in C or C++ code.
> You get processor exceptions when you access a bad pointer, divide by 0, etc. If you don't want the program to instantly terminate, you need to be able to handle those exceptions.
The processor does a lot of other things too, all of these functions can simply be wrapped with the appropriate Result<T,E> and Option<T>. Hell, I'd argue that for a system programming language all mathematical operations should return Result<T, ArithmeticError>.
How is a try/catch better than a pattern match on the exact errors that you care about? inb4 checked exception, which is just an inferior version of algebraic data types.
Exceptions, on the other hand, can come from code you don't control. Some DLL that you call into doesn't care if you like Option<T> better, it's still going to throw an exception. It could be designed to "throw" using the C++ exception syntax, or it could simply be dereferncing a null pointer or dividing by zero, causing an exception.
So then the issue becomes support for catching the exceptions caused by code outside of your control.
One option is to do nothing and just let the program die.
The other option is to catch the exception, automatically save the user's work, then restart the program.
- memory safety
- concurrency
How Zig addresses them:
- it doesn't.
const x: i16 = -1 * 32768; // valid
In the text it's i32
Typescript has almost identical semantics to Javascript but adds typing syntax to improve developer experience and make it easier to manage a large-scale JS codebase.
Zig is a fundamentally different language than C that has a lot of new features. Two great examples are comptime and allocators which have complements in C, but are really very different from what C provides.
If you're suggesting that zig is aiming to provide a C alternative with better ergonomics, then I agree; but zig and C have a lot more differences than Typescript and Javascript.
Zig OTH is "just C" but with a modern syntax, much more correctness, comptime, reflection, generics, a rich standard library, an integrated build system, cross-compiling that 'just works'.
(I'm sure I forgot a couple of things)
Not having the standard define what is compile time and not is a feature not a bug, and one of the reasons that C continues to enjoy healthy compiler development. (I'm not taking a stand for or against Zig, other then that i support the development of new languages)
-No guarantee that its computed at compile time. -Limits what the compiler lets you do when using the constexper keyword. -Adds no performance benefits or guarantees. -Makes your code not portable to most C compilers.
On top of this it adds implementation burden for compiler, and complicates the specification significantly.
For example, we write the reference TigerBeetle client implementation completely in Zig, then wrap this with the C ABI, and then bind to this C ABI from all target languages, to increase our velocity in how quickly we can ship language clients.
More details, in general, around our clients here: https://tigerbeetle.com/blog/2023-02-21-writing-high-perform...
I guess the people downvoting my assume I’m hostile to Zig. I’m not. I’m saying that the stability of C is a selling point and I’m curious what features of Zig are so valuable that people are giving that up.
Isn't it that the OOM killer is likely to be a much bigger problem? With the overcommit enabled and OOM enabled I don't think I can envision the case where you would run into a failed allocation - allocations on Unices practically never fail. It is the OOM-killer that will likely kill your process once it detects that the pressure on your ram+swap is high so you won't even have a chance to run into the OOM.
OTOH if you disable the OOM-killer there's another and much bigger problem to solve - a kernel panic. I guess that's not the condition you want your system to run into.
So, I think that the only combination where you can deterministically detect and run into allocation failures is when both OOM-killer and overcommit are disabled. That's what I think Windows is doing by default.
The reason C# took off is that it's as close to C/C++ as possible. If there is a difference, it's due to a fundamental semantic change. E.g. it moved from painstakingly reinterpreting hundreds of small header files for each compiled source file to loading efficiently serialized hierarchies of class definitions. Hence #include got replaced with using. It changed the semantics of pointers vs. references, hence 'ref' instead of '*', and so on. But there is no "fn square(x: u32)" instead of "uint32_t square(uint32_t x)" just for the sake of it.
And that's the main reason why many people will never consider even looking into the advantages Zig offers. Keeping another syntax in your head is just not worth it.
Many people that could be bothered to learn Zig syntax, jumped ship to Python/Java/whatever already.
I don't think anyone is seriously "jumping ship" from Zig to those.
Those who haven't will have a much lower tolerance for changes. Survivor bias of a kind.
It's about choosing the right language for the task at hand. Some work will really be suited (or only be feasible) with a low-level language and vice-versa.
If I'm writing a command-line tool on the order of ripgrep or working on a microcontroller embedded in a dishwasher, I'm not going to go to Java. That would be weird and awkward. And if I'm writing a new 3D AAA-level game, I'm going to jump to something maybe even higher level like UE5 - trying to do all that in Rust would be a PITA.
As an aside, I really like that I can grep source files for a keyword such as `fn ` and get a good idea of how many functions I am defining, and where they are. This gets especially powerful with multi-cursor editing.
C# actually only resembles C rather superficially, idiomatically there's a large gap and of course you should write idiomatic code, for example it's true you can write a C-style for loop in C#, but you almost never should, C# has a (not great, but it's something) for-each loop that's idiomatic.
C#'s primitive types look superficially like C types, but behave more like the modern sized types from a language like Rust. They're technically structures, albeit with a more convenient alias keyword. For example "long" is a signed 64-bit integer type, like i64, not some arbitrarily "maybe bigger than int" type as it is in C.
123.ToString() is a reasonable thing to write in C#, it means "Call the ToString method on this integer 123" much like Rust's 123.to_string() -- you can't do anything similar in C or even in C++
This makes the learning curve way easier. You can start meaningfully using C# while using C-style for loops, and eventually switch to 'foreach' once you realize it's better.
If I wanted to give Zig a try today by using it for some small low-priority task, I would keep stumbling on these minor syntax differences all the time, and would eventually give up and do it in C/C++ because the overhead outweighs my curiosity.
Most pragmatists don't have infinite time to learn a new programming language for fun. They have a very limited amount of attention and tight time constraints, and will move to the next pragmatic solution if it starts looking like the current one is not cutting it.
I can't necessarily fault Zig for the state of this so early in its lifetime, but - that should not be a big problem, handling transition is work for the diagnostics.
Suppose I write this in Rust: printf("%d", count);
Rust says it can't find a function named printf, but it suggests perhaps I want the print! macro instead?
OK, let's try again: print!("%d", count);
No, says Rust, % style format strings aren't a thing in Rust, use {curly brackets}
Sure enough: print!("{count}"); // compiles and works.
C has a fair amount of kludge in its syntax. C cannot change its syntax because it would massively break backwards compatibility, which is bad.
New languages do not have this problem - they don't have to worry about backwards compatibility. Because they have that freedom, they should always opt for what they believe is the best possible syntax. I'd say they're obligated to do so. Otherwise, we're stuck with another 10-20+ years of dealing with bad syntax, for no good reason!
If the opportunity for improvement is there, and it's nearly close to free to do so, it should absolutely be taken.
Heck, the simple fact that type signatures can be read literally from left to right (instead of using the spiral rule) is enough for me to switch. https://zig.news/toxi/typepointer-cheatsheet-3ne2
The "new" style used by Go, Rust, Zig, Typescript etc... is a lot easier to read because it always 'resolves' from left to right.
I think you got it upside down. Syntax is the least of its problems. Any experienced developer has already learned several languages and can pick up new syntax quickly. Zig syntax is straightforward, similar to other languages, and can be learned in a couple of hours. Easy stuff. The problems with Zig are more related to uncertainty around long term support, the network effect, and so on.