Having distinct types for P0 and P1 is deliberate and is what is called "type state programming" in the embedded rust book [0]. The advantage is that you can prevent misconfiguration at compile time (ensuring that a given pin cannot be used in multiple places). In the Zig example, it seems to me (and I have zero knowledge of Zig, so sorry if this is inaccurate) that you can potentially introduce bugs where the same pin is used twice.
For a generic led driver, it should not use these types, but instead the trait types from the embedded_hal crate, such as "OutputPin" that is implemented by the different chip-specific HALs. There is an example of a generic led driver that uses these traits at [1].
In general I recommend everyone who wants to try out Rust on embedded to read the embedded rust book, because it clarifies a lot of the reasons and advantages of its approach.
Author here. I agree that the Rust embedded books are a nice read, and the idea of type state programming --- taking advantage of Rust's ownership and generics system to enforce at compile time transitions between logical states via "zero-sized types" --- is interesting and could be useful in some contexts.
However, that is not what is happening here.
P0 and P1 are distinct types because they are distinct hardware registers.
I think it's great that they're modeled as distinct types; the problem is simply that Rust makes it difficult to conceptually iterate over distinct types (regardless if such iteration occurs at runtime via a loop or at compile-time via an unrolled loop, as per Zig's `inline for`).
An aside about "type state programming": Microcontrollers have a lot of functionality packed into the pins (see the STM32 "Alternate Function" datasheet tables).
Trying to model all of that using ownership of zero-sized generic types would strike me as a "when all you have is a hammer"-type situation.
If a single pin switches between, for example, high-impedance, gpio low, and PWM output depending on what mode your firmware is in, I suspect it'd be a nightmare to pass owned types around Rust functions --- one would have a much easier time (and more likely to be correct) if they checked their design using something like TLA+ / Alloy or implemented the firmware using an explicit statecharts runtime like Quantum Leap's QP framework https://www.state-machine.com/.
Even if you didn't have Output Pin, couldn't you just declare a sum type?
enum MyPin { P0, P1 }
Edit: feel free to ignore, read your answer somewhere else about this
You would then have to pattern match when you read the value but I don't see a reason to reach for macros or anything more complicated.
That said, really enjoyed the read (and I'll definitely try zig at some point, if only for the speed / compile experience), even if my experience with Rust didn't match yours; my background is a bit different though, I worked with C++ and Haskell in the past, which definitely made rust feel almost natural.
Overall I'd say that the compiler helps me not to keep a lot of rust syntax in my mind and just try things until it works
>An aside about "type state programming": Microcontrollers have a lot of functionality packed into the pins (see the STM32 "Alternate Function" datasheet tables). Trying to model all of that using ownership of zero-sized generic types would strike me as a "when all you have is a hammer"-type situation.
I second this. The idea of checking that a pin is "only used in one place" doesn't really jive with how I think about microcontroller programming. It's very common for one pin to be used for multiple distinct purposes at different times.
There's also a lot of different ways of conceptually slicing pin states. For example, if you are charlieplexing LEDs than you'll switch pins between 'input' (high impedance) and 'output' modes, but at a higher level the pin is serving a single function.
> The idea of checking that a pin is "only used in one place" doesn't really jive with how I think about microcontroller programming.
The borrow checker is not checking that the pin is used in "only one place", it is checking that you don't use the same pin for two different purposes at the same time.
It make sure that you configure your pin as output pin before using it as an output pin, and that you reconfigure it to input pin when using it as such.
(And there are some escape hatch to use when the type system is not sufficient to express that different code paths are disjoint, like RefCell, with runtime check, or unsafe)
Borrow checker tracks who is using what over time. The can prevent concurrency and uncoordinated mutation, use after free type problems.
Type system checks how it is being used.
Both are tools and can used to help ensure a correct program. It really comes down to how these _tools_ are used to help the programmer and the team deconstruct and manage a system to solve a problem.
I think petgraph [1] is an excellent example of relaxing some of the constraints imposed by the tools (borrow checker, type system) to make a system that was easier to use and extend. These things are much more continuous than we realize, it isn't all or nothing.
In a lot of ways, I wish Rust's complexity was a little more gradual, or that we knew how to use it in a gradual way. Use of Rust features encourages C++ levels of complexity. Use of Zig features encourages C-ish levels of complexity.
Zig is to C
as
Rust is to C++
I also think the author had a much better model of the system and the hardware and what they wanted to accomplish during the rewrite and could better project the problem domain and the language together.
Learning Rust and the problem domain at the same time is extremely difficult and in a way leads to a perversion of both.
What do you think about modeling the hardware as a "Resource" register, port, memory, etc. Then modeling a type over a collection of resources.
The question that I would ask myself when trying to use Rust and the features it has out of the box is, "How much fine grain rigor do I want Rust to model for me?" For the keyboard scanning code, in asm or C, one might just have a function `get_keyboard_state(*keyboard_buffer)` but this exposes a sampling problem and would require the programmer to determine state transitions. So maybe a channel or an iterator would be better. Then we might nee to run it in an ISR, the hardware it uses might be multiplexed with other parts of the system, etc.
Every Rust feature needs to be weighed, or rather, given a complexity budget, every Rust feature needs to be purchased.
Zig is awesome BTW, but it doesn't make the same correctness guarantees that Rust can.
> Borrow checker tracks who is using what over time.
This is a very imprecise statement. Do you mean tracks at "compile time" or at "run time"?
A more accurate statement would be -- the borrow checker enforces a specific set of rules and constraints at _compile time_ only. But this set of constraints guarantees memory safety at run time (with the exception of unsafe code). In fact, Rust's runtime is minimal -- it handles panics but has no GC or other bells and whistles. The fancy things are in libraries.
Ah, I was going on what the OP said ("ensuring that a given pin cannot be used in multiple places").
That seems sensible, but also not particularly valuable. A lot of the time it makes sense both to 'read' and 'write' from a pin (e.g. if it's open-drain with a pullup).
> It's very common for one pin to be used for multiple distinct purposes at different times.
Anecdotal, but as someone who works in this space I haven't found this to be the case. In my experience, any particular pin is wired up for a specific purpose, and so the firmware usually just sets it to that mode as appropriate. Generally if it's found that the needed peripherals couldn't be multiplexed to pins without conflicts, it's time to move up to a package with more pins brought out.
I'm currently working on a relatively involved firmware for ATSAMD21 in Rust, and have mostly enjoyed the experience. While some of the language concepts have taken me a while to get comfortable with, and we're still figuring out parts of the ecosystem, it's quite usable and the tooling is a huge improvement over anything else I've seen.
I agree that iterating over types of a tuple is indeed not easy, but in that case, it should be trivial to iterate over an array of `&dyn OutputPin`. Why is that not working in this case?
Interesting write-up! I've barely used Rust but had/have a similar feeling. It's really more akin to C++ and really powerful but also pretty complex. For smaller MCU projects it just feels like overkill.
> Microcontrollers have a lot of functionality packed into the pins (see the STM32 "Alternate Function" datasheet tables). Trying to model all of that using ownership of zero-sized generic types would strike me as a "when all you have is a hammer"-type situation.
The whole idea of utilizing TLA+ for a system level check really does seem like something that would be awesome, even if it's unclear how much effort it'd require to instrument an entire project with TLA+.
> the problem is simply that Rust makes it difficult to conceptually iterate over distinct types (regardless if such iteration occurs at runtime via a loop or at compile-time via an unrolled loop, as per Zig's `inline for`).
Rust just brings a lot of incidental complexity along and still makes some things really difficult. Perhaps it's better in the long run but it's just harder to work with.
Similarly, I wanted a simpler language than Rust and started using Nim last summer for embedded projects. Primarily since it compiles to C which let's me target ESP32's and existing RTOS'es without rewriting everything or trying to get LLVM based tools to work with GCC ones. However, it also embraces `lazy` compilation approach to code and it's standard library.
I wanted to try your example in Nim. Here's roughly how your example would look in Nim (it appears to duck type fine as well):
var
# normally just import these from the C headers as-is
# but this lets us run it
p0* : RefPort0 = addr port0
p1* : RefPort1 = addr port1
var rows = (
( port: p1, pin: 0 ),
( port: p1, pin: 1 ),
( port: p1, pin: 2 ),
( port: p1, pin: 4 ),
)
var cols = (
(port: p0, pin: 13 ),
(port: p1, pin: 15 ),
...
(port: p0, pin: 2 )
)
proc initKeyboardGPIO() =
rows[0].port.pin[rows[0].pin].dir = output
for item in rows.fields:
item.port.pin[item.pin].dir = output
I've toyed with the thought of adding TLA+ hooks into Nim similar to Dr Nim (https://nim-lang.github.io/Nim/drnim.html) using the effect system. Not sure if Zig has an effect system for a similar setup.
> In the Zig example, it seems to me (and I have zero knowledge of Zig, so sorry if this is inaccurate) that you can potentially introduce bugs where the same pin is used twice.
Given the code in the blog post, yes. Here's a possible solution:
pub fn initKeyboardGPIO() void {
comptime checkPinsAreUnique(10, rows);
comptime checkPinsAreUnique(100, cols);
...
}
fn checkPinsAreUnique(max_pin: usize, elems: anytype) void {
var seen = [1]bool{false} ** (max_pin + 1);
inline for (elems) |x| {
if (x.pin > max_pin) {
@compileError("Found pin value higher than max pin");
}
if (seen[x.pin]) {
@compileError("Found duplicate pin!");
}
seen[x.pin] = true;
}
}
There's also other ways of approaching the implementation depending on the required level of dynamicism, I just hacked together the quickest solution I could think of.
Would it be correct to describe this as using comptime to enforce system level constraints? To my naive understanding it looks like comptime combined with type state programming gives one user definable type systems.
What is checked at compile-time in Zig is up to the Zig code. It's a little hard to explain because this doesn't work like Lisp (or Rust) macros, but, since Zig is so easy to learn -- despite this revolutionary design -- should mean it's not a problem. As a first approximation (somewhat inaccurate), you could think of Zig as an optionally typed dynamic language that can run introspect (and create) types freely, perform elaborate checks on them etc. (e.g. examine fields and their types, and compare them to other types' fields), and then the programmer gets to say: run these checks at compile-time and make errors compilation errors.
It's not about what Zig has but what it doesn't have. Because low-level programming is already complex, language simplicity is a super-important feature that few low-level languages have, and I would say none that are expressive and emphasise safety -- except Zig.
You could do those things in C++ with template games and in Rust with macros. But Zig lets you have immense expressivity with a simple, small and easy-to-learn language.
> You could do those things in C++ with template games and in Rust with macros. But Zig lets you have immense expressivity with a simple, small and easy-to-learn language.
const fn is (or seems to me to be) exactly what comptime is though. The difference is that rust's const syntax is still slowly allowing more things to be executed at compile time. Like for now, it still can't do any heap allocation.
Zig's unique power and killer feature isn't having comptime; it's having little else. That's a feature C++ or Rust or D or Nim simply can never, ever have, and it's an extremely important feature, especially in low-level programming. You can do in C++ anything you can do in Zig; but you can't do those things in a simple language you can quickly learn and fully grasp.
Take this with a grain of salt but from the little examples I've seen it looks like a nightmare for any type of large application. I would much rather have increased power in the type system rather than having arbitrary code run and fail builds in an ad-hoc fashion.
It isn't "arbitrary code." It is strictly less arbitrary and ad-hoc than Rust's macros. You can think of it more as a programmable type system, although that, too, is not very precise. As to maintenance of large codebases, it is far too early to tell, of course, but note that no low-level programming language has a great record on that front. I think it is because components in such languages are much more sensitive to the implementation details of others (i.e. all those languages have very low abstraction, i.e. the ability to hide and isolate internal implementation details), but low-level programmers know this comes with the territory, and is part of the price you pay for a high-level of control over low-level details.
> It's not about what Zig has but what it doesn't have. Because low-level programming is already complex, language simplicity is a super-important feature
This is what made me love Lua for embedded programming. The more inherent complexity (or "exposed complexity" might be a better phrase) in the system, the less inherent complexity you want in the language.
Doesn't sound like a problem that's worth trying to resolve at compile time through the type system to me. You complicate the common case for a relatively minor benefit.
In Zig I think you can get 99% of the benefit by setting up a framework where you allocate a resource (pin, ppi channel, etc) through a function call. We use this for a testing framework which gives you run-time errors. But with Zig you could probably write this in a way that gives you compile-time errors for statically allocated resources. That should give you a system that works in both compile and run time.
Yeah, you can't totally guarantee that a pin isn't allocated, since a programmer can use the pin without calling the resource allocation function. But I feel like that takes you from 99.99% safe to 99.9999% .. worth in in a few obscure applications, but not in most.
It's not like I've ever seen any issues from allocating a pin twice in embedded programming. On nRF I guess PPI channels is a more relevant use-case. But then you could very quickly find that you need a more dynamic system that can only detect errors at run-time anyway.
There's a tradeoff between catching errors at compile time as you describe, and code flexibility. For example, here's a line from a current project using one of the HALs:
The pin types here are due to this type of programming. They aren't used by the I2c peripheral; they're just for the check.
If you only use a peripheral struct (eg i2c here) in the main function, the type state system makes sense. If you pass it across function boundaries, or use statically like, this, it may not be. The rust HALs and tutorials that use this pattern tend to leave function boundaries etc (where you need to explicitly declare types) out of examples.
That's a generic container class (similar to vector in C++ or List in C#). But! With a twist!
It stores structs in "column major" order in memory (e.g., if a struct had two fields A and B, then in-memory layout would be A...AB...B), and you can idiomatically and efficiently get a a slice of the values of each column.
I.e., it's a datastructure that automatically applies the struct-of-arrays optimization:
I admit that I'm a Rust fanboy so it probably wraps my view a bit, but from personal experience I don't consider unlimited compile-time code execution to be a completely good thing.
Yes, it makes the language more approachable, yes it makes it easier for people who aren't familiar with the language to understand what's going on. That's nice, but that's not critically important IMO. It's nice if you want to impress people on HN, but if you use the language day-to-day you'll get over that stuff pretty quickly.
On the other hand this type of extreme customization means that even for somebody very familiar with the language you still have to be on your toes because innocuous looking code could behave surprisingly due to comptime shenanigans. On the other hand languages with a more rigid structure may end up being more verbose but that leads to code that a proficient coder can unambiguously understand without having to mentally expand comptime blocks or macros.
This is effectively the metaprogramming equivalent of the statically vs dynamically typed debate. Yes, dynamic code is easier to write but it can be harder to maintain and leads to worse compiler diagnostics and generally requires more unit tests to validate that it's doing the right thing. I think macros/comptime behave similarly when compared to stricter, more limited metaprograming like Rust's generics system.
Rust has macros too of course, but they're a pain to write in my experience, and I'd almost argue that it's a feature. You only use them if you really have to, and after careful consideration, or at least that's how I use them.
Zig doesn't have unlimited compile-time execution and what it has is strictly weaker than Rust's macros [1]. Rather, it has one carefully designed construct that is both very simple and very expressive, and yet isn't as weird or as dangerous as macros. It is not "extreme customisation" but just the right amount to make the language both simple and expressive without extreme measures like macros. Zig accepts that macros are problematic, and shows how far you can go without them altogether. OTOH, while macros are common enough in Rust that while you may not write them yourself all the time, you do use them frequently.
I guess that there is some small truth to your allusion to the static-vs-dynamic debate, but Zig does give you errors at compile-time, and elaborate things it can check at runtime are much easier to express than in Rust. But I would say that Rust is a language in a well-known tradition, and is clearly a "cleaned up C++", while Zig is something that we haven't seen before. It is not dynamic in the same sense as dynamic languages -- you get the checks done at compile-time -- but it is not part of the familiar tradition of typed languages or even any low-level language.
I'm not a devout minimalist, but when it comes to low-level programming in particular, language simplicity is a very important feature, and before Zig it wasn't clear it was achievable at all in low-level languages without significantly compromising on expressivity and safety.
(I wanted to read your [1] reference but you seem to have forgotten to add it.)
I agree that comptime is not the same thing as Rust macros, I mostly mentioned Rust macros because I felt it was a gotcha to my argument since my criticisms of Zig's comptime could be levied at Rust's macros.
To be a little more specific in my criticism, the fact that Zig implements generics with comptime is a bit of a red flag for me. I worry, perhaps unreasonably, that it's going to lead to fragmentation in the way generics are handled in various libraries, leading to headaches and incompatibilities. It is a smart solution, but I wonder if it's a pragmatical solution.
It's definitely an interesting approach at any rate, it's great to see all this creativity in systems language. I don't want to sound too critical of Zig, it's a cool language.
> my criticisms of Zig's comptime could be levied at Rust's macros.
Except that comptime is nothing at all like macros, even though, as it turns out, it can replace enough of their use to make them unnecessary in low-level languages.
> I worry, perhaps unreasonably, that it's going to lead to fragmentation in the way generics are handled in various libraries, leading to headaches and incompatibilities.
But generics in Zig are just functions, and so the problem should be no better but no worse than any API. comptime is drastically less "crazy" or "weird" or hard to make compatible than macros, which Zig doesn't have at all.
I'd say Rust is much more like Ocaml (with very different memory management) than anything related to C++ (in fact, if you unlearn C++, or know any ML-ish language, idiomatic Rust becomes significantly easier). Ownership types are probably Rust's main difference relative to any systems language, I think the first attempt to bring it to a C-ish language is probably Verona: https://microsoft.github.io/verona/explore.html (though it's very immature)
Definitely not; what do they have in common beyond being statically typed and compiled?
Where they differ: memory safety, sum types (don't tell me std::variant is a valid replacement), move semantics, having pointers, classes, GC vs RAII, statement vs expressions... That's a lot of differences.
What do you mean 'beyond'? It's not like there are many other languages that have compile-time polymorphism. (Java, Go, C, etc., don't.)
> memory safety, sum types (don't tell me std::variant is a valid replacement), move semantics, having pointers, classes, GC vs RAII, statement vs expressions... That's a lot of differences.
ML ignores the real performance and architecture considerations, so yeah, of course it is a simpler and more 'elegant' language. As a teaching aid, yeah, I think all C++ programmers should be forced to program something in an ML-derived language.
But once you start handling the real-world edge cases and requirements you'd end up in a place very similar to C++.
> What do you mean 'beyond'? It's not like there are many other languages that have compile-time polymorphism. (Java, Go, C, etc., don't.)
Java does, it's called generics. Also D, rust, Ada, free pascal, nim, and most statically typed languages from the last 3 decades (even Go is finally getting them ). Still can't see why C++ is closer to ML than C, since it's literally an almost compatible superset of C.
Your points are in stark contrast to the reality of the article. The Rust code produced is the one you have to be “on your toes” with due to unnecessary complexity, while Zig, despite the compile time features, is completely straightforward to understand. Maybe a second reading is due.
I don't think comparing zig's comptime with rust macros is actually that apt of a comparison. I tend to think of rust macros as syntactic sugar. Zig's comptime permeates the language thoroughly and in fundamental (and IMHO very pragmatic) ways. Top level const expressions are automatically comptime, modules are comptime structs, static polymorphism is used all over the stdlib (e.g. std.mem.eql), heck std.debug.print is written without special compiler tricks thanks to how cleanly you can use comptime in zig.
I think the idea that zig is a very sharp knife is true, but the overlap of footguns and comptime is not as big as one would think (@fieldParentPtr mistakes comes to mind, but that's sort of about it)
> Zig's comptime permeates the language thoroughly and in fundamental (and IMHO very pragmatic) ways.
TANSTAAFL. Compile-time evaluation cannot truly "permeate" a language because most practical languages preserve a phase distinction between compile time and runtime. (This phase distinction is somewhat softened, e.g. in interpreted languages as well as in advanced PL's which include such features as dependent types). For a system programming language like Rust which relies on this clear-cut phase separation, macros and proc macros (as well as `const`-marked expressions and functions) are ultimately more elegant.
constexpr is basically making more of C++ available at compile time -- e.g. lots of C++11, 14, 17, and 20 are just allowing more of the language at compile time. Including STL, allocators, etc.
Zig simplifies everything by designing in comptime up front, rather than gradually opening it up with ad hoc rules over 10+ years.
My understanding is that Rust is going down the same path as C++. So you're going to have 2 kinds of macros AND comptime-like/constexpr-like compile time execution.
Regardless of the particular trade-offs the various languages under discussion are choosing to make, it’s exciting to me that more and more languages are adapting, exposing and using “compile time” systems
> On the other hand this type of extreme customization means that even for somebody very familiar with the language you still have to be on your toes because innocuous looking code could behave surprisingly due to comptime shenanigans.
Only thing I can think of is something blowing up when cross-compiling to a different target because there's no code in the static if branch to handle that architecture (e.g. stdlib doesn't officially support WASI). But that's not really comptime's fault per se IMHO; you can break things in similar ways in golang, for example.
Unlike C, zig's comptime doesn't have grammar altering abilities
D is another gem (while established in its own circle, it's not mainstream, therefore a "gem") I'd suggest people to give a try. Also Nim for crazy powerful compile-time features.
There's more to a language than just features and functionality though. Why does Clojure have adoption despite being the least powerful Lisp, created decades after Common Lisp? Its stdlib, default data structures, and even syntax make it compelling (yes, sometimes having a deliminiter other than parentheses is helpful, and no being able to define new syntax via macros is not the same thing because defaults are important). Its not bad to rehash language features that have existed for 50 years if the resulting language has other compelling qualities.
Depends on what you think the cause of the lack of adoption is. If you think it can be fixed by doubling down on existing languages, then sure. But I don't know if that's addressing the cause (because I don't know the cause).
I use Lisps all the time and love them. But I recognize that most people don't like them. At some point we have to meet people where they are, if we want to have broad impact. Ideas on their own aren't good enough, they need to be packaged up in the right way, approachable to the right people, marketed appropriately, etc. The tech itself is just a small part of what it takes for an idea to create impact.
Yes. But you could see the new crop of native languages as: as close to LISP as possible without codegen in the runtime. There is this 2x gap between JIT and AOT.
(EDIT: Other than that I'm not sure if any of the new kids can introspect on the content - lines of codes - of a function, like LISP would).
Lisps support AOT compilation since several decades, image tree shaking, and actually having a compiler in the runtime allows for tooling that most languages lack.
The problem is that all of these languages are at least partially derived from C or C++, where memory layout is inverted relative to something like Fortran (which seemed to get this right from the 1960s) when you consider most cache lines on most processors. Therefore you must either go through hoops yourself tinkering with layout in languages not built for it or add much more complexity to a optimizing compiler (like gcc or llvm). I feel Fortran got this right, and Pascal and C was where languages flipped the norm. I kind of get why they did this, because in the 80s and 90s there were so many varying architectures, the memory wall was a very real thing. Actually Simula60 (a monte carlo language), probably had the right abstraction level (everything is just a block), but this was before things like stacks, heaps, trees, and other data structures were permanently etched into people's brains.
I like how Julia implemented this (but they use Fortran-like defaults, with clear inspiration from matlab, numpy, etc), with a relatively compact set of functions to do any sort of "index ordering": https://julialang.org/blog/2016/02/iteration/
Rust is very much a child of Ocaml (with a lot of idioms form haskell) with much more control of memory than pretty much any other language (which probably makes it better for implementing complex or safety critical things like optimizing compilers or an operating system). Actually, learning any ML language is probably easier than Rust, but you'll get more fluent at a ML-like (expression based) language like Rust. For me, I felt like I understood Rust way more after looking at how rustc works and started unlearned everything I knew about C/C++.
> C or C++, where memory layout is inverted relative to something like Fortran (which seemed to get this right from the 1960s) when you consider most cache lines on most processors.
How do you mean? As I understand it, the only difference in these languages in this regard is in 2D arrays, where in C, the current row is in the current cache line and in FORTRAN the current column is in the current cache line. It seems to me you are if anything slightly more likely to want to do several operations on the current row, so the C way is better. What am I missing?
I assume you mean a widely-used one. There's a handful in development. Have you looked at Redox? :)
Ed: I really don't mean this to be snarky, even though looking back it kind of sounds like it. Sometimes you just have to stop fretting about phrasing, slap a smiley on and ship the thing...
Zig's design is so radical, that it completely rethinks how low-level programming can and should be done, rather than improve on one of the existing low-level programming philosophies (C or C++'s). That the result is such a simple and easy-to-learn language that, despite being so radical, doesn't feel foreign is truly an accomplishment.
> In particular, that much of the complexity I’d unconsciously attributed to the domain — “this is what systems programming is like” — was in fact a consequence of deliberate Rust design decisions.
I also thought that, and, to be fair to Rust, it is following the tradition of C++ and Ada, two low-level languages that would also easily make the top five most complex languages in history alongside Rust. Until Zig showed up, I, and I think many others, didn't believe that an expressive low-level language could be made so simple, certainly not one that values safety.
Personally, I find the "bring your own allocator" philosophy to be pretty radical. Yeah, other systems programming languages can facilitate additional allocators beyond "the" allocator for the language's runtime, but Zig seems to optimize for that case, which makes it a lot more intuitive from a learning perspective (no more guessing about where the memory lives). Even Rust (last I checked) defaults to assuming some global one-size-fits-all allocator.
There's also Zig's flavor of compile-time code / metaprogramming. It's probably less powerful than Rust's macros, but I feel like it's a lot cleaner and intuitive, and I'd argue that being able to run ordinary Zig code at compile time is powerful enough of a feature for Zig's use cases. Ultimately, it's a nice happy medium between full-blown metaprogramming (like in Lisp and - from what I understand - Rust) v. preprocessing (like in C/C++).
And yeah, I'm sure there's plenty of prior art for everything that Zig does, but I don't know of any other languages that combine these things in such a simple and intuitive and principle-of-least-astonishment-friendly way Zig does.
In Zig, you can provide a different allocator for a single data structure (or even distinct instances of the same data structure). There is no "global" allocator (what Rust lets you swap out.)
This is vastly more powerful, I have used this to tailor an allocator to specific data structures for better performance.
That is what they're referring to, it is a single global allocator, rather than a per-data structure or per-instance one. You can do this in Rust, there's just no abstraction for it. One is coming.
I don't know Zig, but it sounds like Zig allows to use arbitrary allocators for anything. The abstraction Rust is getting will only work for things that do account for using arbitrary allocators. Anything that doesn't will end up using the global allocator. That's a significant difference.
To clarify, it's up to the function being called; the convention set by Zig's standard library is that if a function needs to allocate memory, then the allocator (specifically, a struct of function pointers to an allocator's implementations of realloc and shrink) should be one of the function's arguments.
There is of course nothing stopping a function from ignoring this and using the C global allocator if need be (or, as I've done in some experiments, using a C library's custom allocator - in my case that of SQLite).
(EDIT: from what I understand, there's technically nothing stopping C from using this sort of strategy, either; a struct of function pointers ain't exactly exotic. It's just a matter of libraries being written with that convention in mind, which doesn't seem to be very common.)
In a large complex application you are going to want to use the same allocator everywhere, or close to everywhere, and almost all functions may allocate memory directly or indirectly, in which case this Zig convention will require most every function to have a useless parameter. That sounds enraging.
A large complex application seems like exactly the kind of environment where being stuck with a single allocator would be enraging. I personally like the idea of being able to give each component of a large system its own fixed chunk of memory (and an allocator over that chunk), such that if one component goes crazy with memory consumption it's isolated to that component instead of choking out the whole system.
- As you mentioned, if I'm editing a document, it's useful to have an allocator on a chunk of memory dedicated to that document. When I close the document, I can then simply free that chunk of memory - and everything allocated against it.
- If I'm implementing an operating system, I'm probably going to want to give each application, driver, etc. a limited amount of memory to use and an allocator against that memory, both so that I can free a whole process at once when it terminates and so that a single process can't gobble up memory unless my operating system specifically grants it more to use (i.e. by itself allocating more memory and making the process' allocator aware of it).
> When I close the document, I can then simply free that chunk of memory - and everything allocated against it.
You probably don't want to do this directly. Instead you want to walk the object graph and run cleanup code for everything in that graph, because in general there will be resources that aren't just memory that need to be released, and for consistency with normal operation, it should deallocate memory as it goes.
You probably don't want to allocate an actual "chunk of memory" either. That just creates unnecessary fragmentation. All you really need is accounting and the ability to report when you're consuming too much memory.
Your driver example is not an example where you would allocate memory per software component. You would actually want to allocate per device, not per driver module; it's just confusing because in many cases there is only one device. But if you can plug in many devices that use the same driver, you'd want independent allocation accounting per device.
> in general there will be resources that aren't just memory that need to be released
Zig already handles this with its "defer" feature; as a resource goes out of scope, it can be released automatically. In the document example, that document's existence would likely be a running function, and as that function terminates, it would likely have "defer" statements kick in that free the document's chunk of memory and release any file descriptors and such.
> You probably don't want to allocate an actual "chunk of memory" either. That just creates unnecessary fragmentation.
If anything that should help reduce fragmentation, or at least help reduce its impacts, since you have better control over whether that allocation exists as a contiguous block.
> All you really need is accounting and the ability to report when you're consuming too much memory.
Which is trivial to do when you know for sure that a given component can only work with a given chunk of memory.
But yeah, nothing stopping anyone from implementing an allocator that cares nothing about where its bytes actually live, and just keeping a running tab of how much memory it's used. That is: using custom allocators is an elegant and simple way to implement that accounting, since that's basically what an allocator already is.
> But if you can plug in many devices that use the same driver, you'd want independent allocation accounting per device.
We're probably talking about the same thing here, then, but with slightly different terminology (and perhaps different structure); I'd be pushing for each device to be controlled by an instance of a driver (much like how an ordinary process is an instance of a program), and it would be those per-device instances that would each have their own allocator. Those instances are what I'm calling "drivers" in this context; they might share the same code, but they run independently (or at least they should run independently; a single malfunctioning disk shouldn't bring down all the other disks).
> that document's existence would likely be a running function
No, that would mean an application managing multiple documents would need one thread per document, which is not normal practice for GUIs. In fact it would then need one event loop per document thread which is not even possible on many platforms.
"defer" simply doesn't serve as a wholesale replacement for destructors, but that's a tangent to this discussion.
> If anything that should help reduce fragmentation
No, there would be fragmentation at document granularity. For example, if you create a document, add a lot of content to it, then delete some of that content, then do that again for several documents, the memory used would be the sum of the maximum sizes of the documents.
> No, that would mean an application managing multiple documents would need one thread per document, which is not normal practice for GUIs.
Unless those functions are async, which Zig also supports (even on freestanding targets!). Single OS thread, single event loop, many concurrent cooperatively-scheduled functions. Or you can get fancy and implement a VM that in turn runs preemptively-scheduled userspace processes, in essence basically reinventing Erlang's abstract machine (and this is exactly a pet project I'm working on, on that note).
And even keeping each document in its own (OS) thread ain't really that unprecedented; browsers already do this, last I checked (each open tab being a "document" in this context) - in some cases (like Chrome) even doing one "document" per process.
> For example, if you create a document, add a lot of content to it, then delete some of that content, then do that again for several documents, the memory used would be the sum of the maximum sizes of the documents.
Would that not also be the case if all those documents used a single shared block of memory? Again, splitting things up helps avoid fragmentation here, especially if you know that most documents won't exceed a certain size (in which case fragmentation is only an issue for data beyond that boundary) - or, better yet, if you ain't storing the whole document in memory, in which case the buffer of actively-in-use data can be fixed. Further, if each allocation is a whole page of memory, then that's about as much control over fragmentation as an application can hope for beyond itself being the OS (and probably won't make much of a difference if those pages are scattered across RAM anyway; swapping would definitely suffer on spinning rust, but that's already bad news performance-wise anyway).
> And even keeping each document in its own (OS) thread ain't really that unprecedented; browsers already do this, last I checked (each open tab being a "document" in this context) - in some cases (like Chrome) even doing one "document" per process.
That is not correct. (Source: I am a former Mozilla Distinguished Engineer.)
Chrome (and Firefox, with Fission enabled) do one process "per site", e.g. one process for all documents at google.com. (In some cases they may use finer granularity for various reasons, but that's the default.) In each process, there is one "main thread" that all documents share.
> Would that not also be the case if all those documents used a single shared block of memory?
No. Memory freed when you delete content from one document would be reused when you add content to another document.
That is being backfilled in; Vec already implements it on nightly, IIRC.
And really, what you're talking about here is "the standard library data structures," which aren't super likely to be used in firmware anyway. It's a lot easier for ecosystem data structures to add support, after all, they already would choose to call the global allocator, so now they can do either. And it is much easier for them to cut backwards-incompatible changes, if they have to.
> which aren't super likely to be used in firmware anyway.
Why not? Zig's std library is specifically designed to be usable for freestanding/baremetal targets (e.g. firmware), and the compiler is smart enough to only include the parts of a library (including std) that are actually used. If you do need to reimplement a part of std, you can just... reimplement that part, and import your own implementation instead of the one from std.
I am talking purely about Rust, yes. Firmware tends to use libcore, and if it does happen to dynamically allocate memory, liballoc. libstd assumes you have an OS, so...
I mean, in terms of Rust, it sounds like Zig allows to use any allocator for anything in any crate. Not only structs in std or other crates that explicitly allow a custom allocator. In Rust, and only talking about std, you'd need to change a lot of things to allow e.g. BufWrite, etc. to use a custom allocator. And every crate that uses types that allocate stuff under the hood. But maybe I'm misunderstanding what Zig allows.
You are not misunderstanding what Zig allows, but Rust can do the same thing. https://doc.rust-lang.org/stable/core/alloc/trait.Allocator.... just isn't stable yet. And it's conventional for it to take this as an argument for everything that needs it in Zig.
BufWrite would do it the same as any data structure would, an additional parameter, all the same.
I mean, I'd say you were mostly right, in the sense that the callee doesn't know the implementation details of the passed allocator; it's only aware of the interface (i.e. the struct of function pointers that defines that interface).
What's the use case? I'm trying to think of a situation where you'd want to do that were you might not just make a separate binary that uses the other allocator
Custom allocators are extremely common in C and C++ code, and often improve performance over general purpose allocators (though they can also make things worse if you're not careful).
The C++ STL has custom allocators (though the initial design was sort of botched; there is a new polymorphic allocator mechanism that aims to fix it IIRC)
Zig's docs cover a few different scenarios, but a couple of interest to me at least:
- Arena allocators, where your code allocates a chunk of memory and then creates its own allocator just for that chunk; when that chunk gets freed, so does everything in it. Handy for short-lived data.
- Using different allocators for different regions of memory makes it trivial to compartmentalize things; you could use the OS allocator (or a straight pointer if you're implementing your own OS) to preallocate chunks of memory, slap allocators on those chunks, and hand those to different components, preventing any given component from bringing down the whole program due to a memory leak.
- It's possible to use allocators for verifying code correctness (e.g. detecting memory leaks, testing code under memory exhaustion conditions, etc.).
Zig's main feature is what it doesn't have, and the languages you mentioned don't have that feature.
Other languages also have more-or-less general partial evaluation constructs, but they're not revolutionary because they didn't realise they can express traditional constructs in terms of partial evaluation. Zig is revolutionary in that its simple partial evaluation construct replaces generics/templates, typeclasses/concepts/traits, macros and conditional compilation. The result is something that is consistent, extremely powerful, and yet exceptionally simple.
Hacker news has a really hard time valuing simplicity. I think it’s an egotistical thing: I’m smart so I don’t need a simple language.
What people miss is that a simple a language allows you to apply your smarts to solving the actual problems in front of you instead of puzzling over language features. You can only handle so much cognitive load at once, and ideally the vast majority of that should be devoted to whatever problem you’re solving, not to the language itself.
Ironically, this fact is the same fact that makes complex languages more fun for hobbyists. There’s simply more to explore, and to try, and to solve, when your object isn’t building a product but instead playing with a language. It’s a different purpose, but people very rarely acknowledge this fact, likely because they’d rather pretend their purposes are clearly mechanical and business oriented. It’s ok to just want to have fun sometimes.
It definitely has merit. I've run into this exact same thing in type-system heavy languages like Haskell/Scala/Rust, where I spend more time juggling abstractions than implementing features.
But there is an additional dimension: abstractions often make the first implementation much more cumbersome. But most code is maintained and read much more than it is written, and abstractions can make extending and maintaining a code base much easier.
It's also good to remember that the existence of certain language abstractions doesn't mean you have to use them.
Another point I disagree with the consensus about! Abstractions have their place, but a bad abstraction ends up spending more of its life getting torn down than it does productively simplifying the code. In my opinion each abstraction increases system cognitive load, and so they should only be added “lazily”, ie when empirical experience with the code proves that a particular abstraction would have broad and deep utility.
But simple languages don't mean more cumbersome abstraction -- take Scheme for example -- while C++ and Haskell's maintenance record isn't particularly great (Rust doesn't have one yet).
In all programming languages you must understand how the parser will understand and translate what you write, it sounds like Zig will always know your intentions and maximize your code, it sounds too good to be true
If Zig were a high-level language it wouldn't have been so impressive. There are plenty of simple high-level languages. But in low-level domains there is a lot of extra accidental complexity because you need to precisely define layout, control memory allocation and placement, and try to avoid, or at least control branches or dynamic dispatch. And you need to do all that in the worst-case, and you don't have a JIT. So far there have been two approaches -- C, which is linguistically simple but inexpressive and spectacularly unsafe, and C++/Ada/Rust, which is much safer and much more expressive but incredibly complex. Zig finds a new way, not through some magic, but through really exceptional and radical design.
But low-level languages also get translated by a parser so I don't see the difference.. Maybe you mean that languages has less abstractions and because of this simplier translations? Think I need to try Zig out to get a grip on it..
I mean that in low-level languages the name of the game is how to work with low-level details, and Zig's design makes it exceptionally pleasant as it uses the same language for those details as it does for the logic. There is no magic here, just very clever and careful design.
As mentioned in my other (wall of text) comment, that's not strictly beneficial.
It forces you to implement a lot of logic in "userspace" that other languages do for you automatically.
Complexity for certain abstractions moves from the language to user code, at the expense of consistency, cohesion, totality and (auto-generated) documentation.
It will be interesting to see how things play out for Zig once the ecosystem grows a little and libraries appear, but there are very significant downsides to this approach.
Which of those is more beneficial indeed remains to be seen and might end up being purely a matter of personal taste; the very thing you call a downside I see as an upside. I think that talking about the positives "consistency and cohesion" where composing primitives works as a positive is merely a matter of habit. Zig treats some aspects that other languages sees as primitives as if they were any other part of the language, where code and libraries rule rather than a growing collection of primitives. I do agree that in principle a language could be too unstructured for some domains (Lisp?) but interestingly, Zig didn't go as far as syntax macros, whereas Rust did. Anyway, Zig finds a surprising middle-ground that is, as yet, hard to definitively judge, whereas Rust, for better or worse, is more of the same.
I was thinking of The Lisp Curse[1] while reading your comment and then you mentioned the language! I’m quite excited by Zig (even if safety is lower priority for it than for Rust, it is really pushing the tooling envelope) but I do wonder whether the “anti-composition hypothesis” here holds up for relevant projects today. Many C programs include shims for compatibility between multiple different “library object models” (hell even strings count here) and they seem to be a common source of security and performance issues. Maybe in the domains where Zig is most competitive that dynamic won’t play out? Or maybe comptime provides tools that will still enable composition or at least allow for lower overhead “object model shims”? I suspect that it will be hard to know more about how it plays out until there is more language stability and code sharing, maybe even a repository like npm or crates.io.
Can the "safety is lower priority than rust" trend stop? Zig is not less concerned with safety and will catch illegal behaviour at runtime for that which isn't caught at compile-time. It's only ReleaseSmall and ReleaseFast that elide this where you're able to toggle safety checks via a builtin if you wish. There's ongoing work to provide more of it within the standard library with the GeneralPurposeAllocator being an example of it.
I'm not suggesting that Zig isn't concerned with safety, but it's not a language designed first and foremost to offer certain safety properties. For Rust you can say "as long as you don't write the `unsafe` keyword in Rust, you'll never introduce memory or thread unsafety". Is there an equivalent for Zig? Not AFAIK but I'd actually be quite happy to be shown wrong.
I would put it differently. Rust sacrifices anything -- including things that may hurt other aspects of correctness -- to soundly guarantee (assuming the compiler is correct) no undefined behaviour in its safe subset (and yet makes some concessions, as a large percentage of Rust programs do employ unsafe code, and so don't make such a strong guarantee), while Zig finds a different balance, at times sacrificing possible UB for the sake of helping with functional correctness. Even if you look at correctness only, it is unclear which approach, if any, offers a better story.
Assumption 2: the more we do with a language, the bigger the language has to be.
I am sceptical about (1), and the only way (2) can possibly be true is if the standard library is part of the language (which it really is not: it's user space stuff, curated approved by whoever's in charge). Don't be excessively pessimistic. It's just as irrational as misguided optimism.
In regards to assumption 1: zig's documentation isn't the greatest, and it's still pre 1.0 which may have turned many potential users away for the moment.
I think the person you were replying to implies that after a certain point (1.0 release?), Zig's userbase will increase to such levels that it can be considered "used" by (many) people
My guess is, a few hundred users are enough to identify and correct most of what's missing in the language. Going from there to a million users is unlikely to make a big difference. Especially if the language's features are orthogonal (apparently they are), and the scope of the language is clear (the intended use case at least seems to be).
We'll see how it goes. I won't bet my hat on it, but Zig does seem to be on a good path to stay simple even as it matures.
> Zig is revolutionary in that its simple partial evaluation construct replaces generics/templates, typeclasses/concepts/traits, macros and conditional compilation.
This is what C++98 did, except they called their one true comptime evaluation construct "templates", and they did it by accident. There's a reason why Rust introduced generics and typeclasses separately: C++98 templates as bespoke comptime evaluation was a disaster, and this was clear already in the C++ community.
> This is what C++98 did, except they called their one true comptime evaluation construct "templates", and they did it by accident.
Right, except not at all, because templates' syntactic elements are distinct from the "object" part of the language, so it is not a partial evaluation construct for C++, but rather a separate (and rather complex) meta-language for C++. In Zig there is just Zig (with its superb error reporting mechanism), and comptime partially evaluates it. Zig distances itself from C++'s problematic design much more than Rust, which, when all is said and done, is pretty darn similar to C++.
But that's the problem with revolutionary design. Your ability to compare it to what came before it is limited because it isn't really similar to anything. Luckily, Zig can be fully learned in a day or two, so there's no need to rely on comparisons for long. You can quickly learn it and decide if it's your cup of tea or not; even if it isn't, you'd have learned something quite refreshing and inspirational, and without spending too much time.
I do agree that there is something more mysterious about Zig. Nobody knows how "good" Rust is yet, either, but it's probably no worse than C++ when we factor all elements that matter to C++/Rust developers, and we're willing to accept that it's also probably not drastically better, except maybe when it comes to undefined behaviour. Zig is more of an unknown because it is so different. It has the potential to be worse than C++, but it can also be much better. At the very least, it is very interesting in that it offers a completely new vision for how low-level programming could be done.
I really don't understand why someone would think Rust is "pretty darn similar to C++". I think about my code and data in Rust very differently to C++. C++ doesn't have tagged unions, Rust does. C++ does have inheritance, Rust doesn't. C++ templates are quite unlike Rust generics. Rust enforces safety and (mutable XOR shared), C++ doesn't. All of these lead to quite different design decisions for same-shaped problems.
You're looking at the details, while I look at the overall "feel" and find them almost indistinguishable. They're both low-level languages -- and so, like all low-level languages, suffer from low-abstraction, i.e. the difficulty to hide internal implementation details from consumers behind APIs -- that decided to invest their complexity budget to get the appearance of high-level code once you read it on the page (while the difficulty of changing it is the same as with all low-level languages), and don't hesitate to employ a fair bit of implicitness, grow a large set of features, and let compilation be slow. The details of how they do that are less important; what's most apparent is their shared design philosophy of low-level programming (although I think that Rust improves on C++ and certainly cleans it up). Zig offers a radically different approach, and one that is also radically different from C's philosophy.
I don't think your critiques are accurate, but anyway, the "similarity" here is that you have the same high-level critique of both languages. This does not make Rust "pretty darn similar to C++".
For me, memory and data-race safety and absence of undefined behavior are critical features, but it would be misleading if I were to go around saying "C, C++ and Zig are all pretty darn similar".
> the "similarity" here is that you have the same high-level critique of both languages.
The similarity is that they both espouse the very same design philosophy for low-level programming. It's a pretty big similarity.
> For me, memory and data-race safety and absence of undefined behavior are critical features, but it would be misleading if I were to go around saying "C, C++ and Zig are all pretty darn similar".
It would be misleading, because memory safety and undefined behaviour in Zig is much closer to Rust than to C++. Even where it's not the same as Rust, it's still very different from C/C++. Safety and correctness are as emphasised in Zig as in Rust; they just go about achieving them differently. It is not clear at all which of them achieves correctness better.
> Safety and correctness are as emphasised in Zig as in Rust
This is so far from true I cannot take you seriously.
Zig doesn't have any kind of lifetime analysis, so it's as vulnerable to use-after-free/dangling pointers as C and C++ are. That alone rules out Zig from ever being considered "memory safe" in any meaningful sense.
[Yes, I'm aware of GeneralPurposeAllocator, but that is not something you want to ship in production. "Never reuse any virtual address space" is a disaster for the OS (VMA fragmentation, TLB shootdown IPIs) and the hardware (TLBs, caches). That's why no-one ships such a thing in production for C/C++. GeneralPurposeAllocator will no doubt be useful for debugging (though less effective than ASAN or Valgrind) but safety in production is the game here; ASAN doesn't make C/C++ "memory safe".]
Zig also allows data races so Zig programs can have undefined behaviour via data races on non-atomic values. Again, this cannot be fixed.
Even smart pointers (e.g. reference counting) are nasty in Zig. Zig doesn't have destructors so cleaning up an owning pointer or a refcounting pointer requires developers to write manual "defer" statements. Worse, these only work at function scope so you also have to write manual cleanup code for every data structure containing a smart pointer. Without idiomatic smart pointers Zig will likely be more prone to UAF bugs (and leaks) than C++.
You've misunderstood. There is no doubt that Rust eliminates more undefined behaviour than Zig (though not completely), but it does it at the cost of harming other aspects of correctness. Zig does not try to eliminate UB as much as Rust, but it focuses more on reducing other types of bugs. At the end of the day, you don't care if your program fails due to UB or another bug, and it is unclear which approach results in more correct programs overall.
> you don't care if your program fails due to UB or another bug,
Actually you do, because memory safety bugs are more likely to be exploitable than some arbitrary correctness bug, because they can be weaponized to take full control of the program.
The reality is that UAF/dangling pointers are a major source of CVEs in mature software. Rust prevents those in practice, Zig doesn't. You think Zig is going to be much better than Rust at preventing other kinds of bugs. I see zero evidence of that.
I don't think Zig is going to be better than Rust at preventing other bugs. I don't know. No one knows. Software correctness is a very tricky thing about which we don't know much more than we know. UB are a cause of many bugs, and Zig eliminates many kinds of UB; Rust eliminates more. But Zig is also better at things we also know reduce bugs: simple semantics with simpler analysability, and shorter turnaround, which means more tests. In formal methods research we also have an analogous choice of approaches: more soundness at the cost of higher complexity and effort or vice-versa. There is no point hypothesising about which works better because even the experts have no idea, and it's certainly possible they are about even. The only thing that can settle this is empirical research.
Again, you misunderstand. Both Zig and Rust have much less UB than C. The delta between Rust and Zig comes at a cost to language simplicity and to more testing. You're guessing that that cost's negative impact on correctness is smaller than that positive delta. It's a reasonable guess, but so is the opposite one, and neither is more proven than the other, which would be my guess (while I don't write safety-critical code these days, I worked on safety-critical realtime software where a bug or even a later response could cost the lives of many people; in such correctness-critical domains C is preferred over C++ despite being less safe), although I would even more confidently bet that the real difference, whichever way, is small.
D, Nim, and Haxe had it for quite some time (Along with Jai, which is yet unreleased to the public), although you can argue that Zig’s implementation is conceptually the simplest (it has merged compile time semantics with generic types in a unified way).
One subtle but extremely important feature of Zig's comptime is that is emulates the target architecture. Fundamental for implementing correct cross compilation.
Zig's revolution is not in adding a partial evaluation feature, but in removing many other separate features that can be expressed as mere applications. As Antoine de Saint-Exupery said, "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away."
I see some abstract aesthetic similarities between Zig and Lisp, or some Lisp's at least -- especially their minimalism -- but Zig's partial evaluation (comptime) works nothing at all like Lisp's syntactic macros (there is no quoting construct, and you don't manipulate syntactic terms at all), and, in fact, has much simpler semantics. The result is intentionally weaker than macros -- macros are "referentially opaque" while comptime is transparent[1] -- but Zig's realisation is that you don't need macros -- with their complexities and pitfalls -- to deliver everything a low-level language would need.
[1]: I.e. if x and y have the same meaning ("reference"), in Lisp -- and in any other language with macros -- you could write a parameterised expression e, such that e(x) does not have the same meaning as e(y); you can't do that in Zig.
Thanks. I'm not familiar with Zig. I responded to the "arbitrary compile-time execution" by adding Lisp to the proposed list of D, Nim, and Haxe. Also the latter might raise some concerns when looking at details as you did with Lisp.
For instance, even if x and y have the same meaning, (let (x) ...) and (let (y) ... ) will not. So if you can't do that in Zig, that implies you can't write a custom binding construct.
A let expression isn't parameterised by x and y but binds their meaning, and the bound variable isn't free in the expression, and you can't substitute it at all. A simple example is an expression that prints the syntactic name of its argument. You can do that in C, C++, Lisp and Rust, but not in Zig (this is OK, because Zig obviates the need for that with excellent backtraces). But this means that you can understand all Zig code as simple Zig code, and that there is no complex meta-language with super-powers as there is in all those other languages I mentioned.
I like Zig a lot, but I'm concerned that it's yet another language that leaves memory management up to the developer.
For example, Zig does not appear to have any concept of lifetimes, and does not enforce single mutable ownership. As I understand it, Zig does not have RAII, either, so cleanup (with "defer", etc.) is also left as an exercise for the programmer. Zig has arenas, allowing quick cleanup, but seems pretty bare-bones otherwise.
(I was relieved to see that Zig does not allow unchecked null pointers.)
Great article, and thanks for calling out "guessability" as you call it. It relates to two concepts in computer science, one from programming languages and one from human computer interaction:
1. Orthogonality is the property of a language that it constitute only a small number of concepts, but exactly the ones you need (e.g. C or Scheme are orthogonal, C++17 is not).
2. A good user experience (e.g. of a Web GUI, but also of a programming language) minimizes the violation of expectations of the user (Ben Shneiderman). "Discoverability" has also been used to describe this.
I agree with you that it's desirable for a language that you may have an intuition "it should be written as something like this..." and it just words. Thanks for calling out "guessability"!
The design world likes to use the word affordance: "the quality or property of an object that defines its possible uses or makes clear how it can or should be used"
Zig code looks way more readable to my eyes, damaged by the years of staring at C/C++. Also the learning curve for Zig so far seems to be relatively shallow. The documentation is rather "ok-ish", comparing to Go for example. But it's much better than a few of the other programming languages I've used. I really hope Zig will find the place it deserves in the programming world!
Fewer symbols[1] “look” more readable at first glance (like dynamic languages), and more beginner friendly, but it also means there's less intent communicated and the reader needs to look for the information elsewhere: it's an eternal trade-off.
Also, Zig comptime is extremely powerful, which means you can do many many things with them, but it makes it pretty hard to understand what's happening: you always need to wonder “what will this code become when compiled”. A bit like with super-macro-heavy C code, or even lisp (even though comptime don't even work the same way macro do so you also need to wrap your head around it). In the end, IMHO it makes it “really fun to write, and hard to read”. This, combined with the lack of memory safety[2], probably make Zig the perfect hacker/hobbyist language, but not desirable for production (being the perfect mirror of Rust).
[1] even though Zig isn't a particularly good example for this, when reading real-world Zig code, there's `@` and unusual keywords (`align` `inline` `try` `comptime`, etc.), and (kind of) static typing. Of course it has a lighter syntax than Rust, but it's not like a dynamically-typed language either.
[2] yes, I know, there are some plans to have some kind of op-in memory-safety thanks to runtime checks, which is better than C's “everything is UB and sanitizer are an afterthought”, but still far away from the “proven safe” situation you get when using Rust. It's pretty sad that Zig didn't want to build upon the ownership framework developed by Rust.
I don't know. When rust was first iterating it was basically a different language from what it is now.
I cannot find the appeal of the current iteration. It's very counterintuitive, which makes it really unsuitable for mainstream programming IMHO. Sure, you could argue that it's not intended for mainstream programming and that we want people to know exactly what they're doing, but then you're basically making the same argument Torvalds did for C.
The "why" is memory safety without GC, which as far as I know no other non-toy language provides.
It's also why I feel that the comparison with Zig is a bit unfair: Zig is not memory safe. If Rust was willing to compromise with this constraint it would remove could remove some of the intellectual overhead for the developer and result in simpler looking, if unsafe, code.
But then it would also destroy the one killer feature of the language.
The borrow checker is a Big Deal™, but even outside unsafe blocks, Rust did not go all the way to perfect safety. Safety remains a spectrum, not a binary choice. The extreme end of that spectrum isn't Rust. It's using a proof assistant to mechanically check the correctness of your entire program.
What are you referencing on that page that is memory and safe, the program panicking when attempting to access invalid memory is one of the safety features, it means you have a programming error that you need to correct and it is bailing right now to prevent anything bad from happening.
Oops, after a cursory search, it would seem there's no easy way to disable runtime bounds checking. While runtime crashes aren't ideal, I do stand corrected, sorry.
Still, I think my point about safety being a cursor instead of a switch remains.
whats wrong with current iteration? I think rust has become better with things like lifetime elision? And many new thing like GAT, out of band lifetime etc will make it more better?
Before claiming why its counterintuitive I think you should have provided some example to backup such claim.
Nope, I have no idea which path Zig will take, but I somewhat doubt it will do the same Rust did. Rusts later stage development reminds me a bit of the design by committee approach and it doesn't seem like Zig has that problem, but it's also hard to make it to a mainstream language without a hugely popular project that's associated to it.
I was generally rooting for mainstream usage of Rust, but I don't see it happening with the path it has taken. I also don't really hope it will for the same reasons.
Zig already has the main advantages of being mainstream: first, it is small and easy to learn, which means you can hire any C/C++/D/Rust programmer, and they'll be productive in no time. Second, it binds to C more easily than any other language (save maybe C++), which means you have access to a wealth of libraries already.
Ironically, neutralising network effects like that is perhaps the best way to make sure Zig becomes mainstream, eventually.
Not to lionize andy or anything, but I'm pretty sure that strategies to neutralize these concerns is a deliberate choice in his stewardship of the language.
- There are extremely few jobs that recognise it. I'm attempting to learn C++ because of this.
- Documentation can be lacking as there isn't as much demand for it, or people with time to write it. That said, personal support in small communities can be great.
- Smaller library ecosystem.
- Survival of the language into the future is less certain without the financial support mainstream languages have.
I've used Haxe for years despite these points, it's a great language. A language is more than it's engineering though.
Mostly ecosystem and community support. There are a lot of interesting languages out there, but it's hard to do interesting things with them if they're missing support.
Zig might be in a good position here as it has very nice C interop, which lets you leverage the past 30 years of programming history, but it's still got a ways to go before it will be "ready for primetime" from the look of it.
In many ways, it already has. Zig has been under development for several years now and its syntax and semantics have change a lot since its first iterations. There are still some breaking changes coming that you can find at https://github.com/ziglang/zig/issues.
One of the cooler aspects of Zig's breaking changes is that its formatting tool can automatically update your code to the new syntax.
I am a long-time C++ developer and have been playing around with Rust recently. I really love the language, but one thing I miss about Rust from C++ is the ability to manipulate and play around with types. The features that really enable this are variadic templates and generic lambdas. I wish Rust would get something like them in the future.
In C++17, the author's issues with trying to do port and pin with different pin types, has a pretty elegant solution in C++.
Here is a toy solution.
#include <iostream>
#include <tuple>
/*
for (port, pin) in &[(P0, 10), (P1, 7), ...] {
port.pin_cnf[pin].write(|w| {
w.input().disconnect();
w.dir().output();
w
});
}
*/
template <typename PinTuple, typename F>
void for_each_port_and_pin(PinTuple& tuple, F f) {
std::apply(
[&](auto&&... p) {
auto apply_pin = [&](auto& t) { std::apply(f, t); };
(apply_pin(p), ...);
},
tuple);
}
struct P0 {
void write(int pin) {
std::cout << "Writing on Port P0, pin " << pin << "\n";
}
};
struct P1 {
void write(int pin) {
std::cout << "Writing on Port P1, pin " << pin << "\n";
}
};
int main() {
auto ports_and_pins =
std::tuple{std::tuple{P0{}, 10}, std::tuple{P1{}, 7}};
for_each_port_and_pin(ports_and_pins,
[](auto& port, int pin) { port.write(pin); });
}
Impressive that you were able to pull it off in C++17 like this (and extra kudos for the live link), but the resulting code (both template and invocation) looks very cryptic - except for the part commented out; that one is much more pleasant to the eye.
They are differently powerful. Rust's macros can let you extend the syntax and do context-free code generation, where as C++ can let you to type-directed code generation. You can do the latter in Rust using trait dispatch, but it's more awkward and less expressive than what C++ has.
I think the trick of using a separate file per target works just as well in Rust, where each one imports the appropriate HAL(s) and they all expose the same interface as each other. “Circular” imports across the files should work too. This will help reduce/resolve the scattering of #[cfg]s throughout the code (but won’t help with the heterogeneous iteration).
Another approach for that sort of genericity would be a trait that is implemented for each target. This ends up being a more formal/structured version of the above, since it defines the interface explicitly, but is potentially over engineering.
I was wondering in the P0, P1 issue, why the auther didn't use an Enum, Enums are the easiest way to handle that sort of mixed type.
On my phone something like:
enum Pin {
Pin0(P0, usize)
Pin1(P1, usize)
}
Assuming that would work in the embedded device which I don't know.
I know the above doesn't affect the general point, but I have found lots of people struggling with Rust haven't yet discovered that you can easily combine types in Enums.
And the author's actual rust solution using the 0, 1 as a proxy for the port with a match to resolve it, has effectively implemented the enum solution but without leveraging the language to do it, which means incorrect programs could use any integer value and the compiler wouldn't know, and the author has to handle that case, which they hack with empty block for that case. (relying on knowing they haven't in their own code, basically undoing the whole point of Rust compiler strictness)
Author here. I considered the enum "solution" but found the match on usize tuples to be clearer because it requires less code. Introducing an enum doesn't help because it neither:
+ helps better model the domain: P0 and P1 are already device ports, wrapping doesn't clarify anything,
+ nor does it buy you safety; arguably I'd say it makes it less safe, since the real risk with this sort of code is that you fat finger when copy/pasting between the electrical schematic and the firmware, so by adding extra wrapping you further obscure the pin assignments.
Ah, sorry I added further comment to above before seeing this. I can see why you would say that, but does the arbitrary integer risk of the 0, 1, _ match not create even greater risk? Compiler cannot help at all.
First, we should be clear that this is my goofy hobby keyboard project --- if I was concerned about safety, I'd have written it in MISRA C or something =D
There are many things about this project that a compiler can't help me with. I had to read about all of the pinouts from a PDF, draw them on a circuit board, and then map those pins in the firmware.
The code only deals with that last part, and in this particular example I decided that it was safer on the whole for the code to be obvious (easy to read + compare with hardware schematic) than to go through contortions with types to make some things more checkable by computer but less-checkable by human inspection.
The main risk here is not passing the wrong value to this match, it's fat fingering the transcription from the schematic.
Makes sense, and I guess given the above your conclusion not to use Rust seems like the correct call for your situation. Thanks for explaining further. It's true you say much of this in the article.
Yeah I think people like to match the values of a programming language to ourselves as people. (And by values I mean, correctness, speed, expressiveness, velocity, etc). Like, I’ll pick the values which I like the most and find languages which match the values I aspire towards. Eg maybe I like the idea of my programs being strongly typechecked without sacrificing speed - so I program in rust. Then I write clean rust code even when I’m doing a quick and dirty prototype.
The better & harder approach matches a language’s virtues to the problem at hand. Strict correctness doesn’t matter much for a hobby program like this - moving fast and having fun are probably more important here. Expressing that with your tools might mean using Zig, or using rust but being sloppy with allocations and .clone() because it’s fine. Or treating rust like C and using unsafe everywhere. “Making invalid states inexpressible” isn’t that important in a fun side project - unless maybe that’s fun for you!
The right question isn’t “What is my favourite tool?”. It’s “If this project had a soul, how would it want to express itself through code?”
> The right question isn’t “What is my favourite tool?”. It’s “If this project had a soul, how would it want to express itself through code?”
I somewhat disagree. What you're saying is basically "pick the right tool for the job", but more poetically (not a criticism, BTW, I like your phrasing).
But what I think this is missing is that the "right" tool is at least in part based on your favorite tools are.
Or to put it differently, using a tool that may be suboptimal for the job, but which you know extremely well, may be better than using a tool you don't know which is optimal for the job.
Of course, this is much less of an issue for hobby projects, and if one of your goals for a project is "learn new stuff" (a goal I often have for my own hobby projects), then picking the optimal language may be exactly the right choice. You get to learn a new thing while not fighting with the language.
I had a think about this and I agree. There’s some tension here - you want breadth, but you don’t have enough time to get good at every language and framework. Maybe the right approach is to have familiar ground in each domain you find yourself. Pick enough languages and frameworks so you have trails in any terrain you want to tread with your work. You don’t need to be an expert in both php and rails, in both Java and C#, or both unity and unreal. But you want enough scope that if you want to throw together a quick and dirty UI prototype, you have familiar tooling you can call your own.
For me, when I want to make a quick UI prototype I reach for JS and Svelte. Because I’m comfortable there, its not worth it to also be an expert at rails, and Php and SwiftUI and C#/WPF. But if the only tool in my toolbox was Rust, or C, or Unity or something, I’d be much worse at prototyping user interfaces. The inverse is also true - if I wanted to write a database but only knew JS, I’d be in for a rough time. You want a home base in each domain.
Oh and also, if the whole separate types for each mapping thing is so annoying and wrapping the tuples is unhelpful, which is understandable, then perhaps safer to wrap just the pins in emums and then always access them via the enum?
Then it would be (enum type, usize)
Then also at times where all Pins need handling, the.exhaustive matching can reduce risks of not doing so?
This is something I have been reading more and more lately - "Rust is complex". In the past, people usually brushed it off saying that it's much simpler than C++. But that always felt like saying that a mountain is not very high because it's smaller than the Everest
Rust's philosophy is "frontload the problems", thus complex problems are complex right away. It means it takes a lot of thinking about design, fiddling with data structures, types, looking for elegant design optimizations, but then it works as "intended", compared to a lot of other tools/langs.
Here the author states that the hard (error prone) part is not the coding, but the transliteration from the manufacturer's data sheets. So Rust seems to be adding complexity for no gain at all. (Which is completely fair for a hobbyist project for a keyboard firmware.)
Does this mean Rust should only be used for big systems where that mandatory explicitness about complexity pays off? Does this mean Rust perhaps would benefit from a mode where certain modules/functions are type checked in a different way? (Or that would just make the language even more complex for no significant gain during programming?)
It has essentially become the very thing it sought to destroy. Choosing Rust over C++ is now mostly about the vastly superior package ecosystem, thanks to Cargo.
Rust's tools are better for common cases, but C++ has the vastly superior set of libraries: GPU, embedded, robotics, desktop, mobile, etc.
I recommend watching CppCon and being amazed at how much work is going into the C++ ecosystem right now. Rust is popular in some circles but the programming world is extremely big.
I mean, the author could have removed most of the complexity by using Rust HALs.
They just decided to reimplement from scratch their own different solution. That’s completely fair, but doesn’t allow you to make the complexity argument.
It would be like deciding to start a C++ project without using the C++ std library and arguing that C++ is hard because you had to reimplement std::Tuple...
As a long-time embedded programmer --- who has written keyboard code before --- I feel like "keyboard firmware" and a HLL really don't belong in the same sentence, and there's far too much abstraction in the examples the author provides.
Say I need to initialize all the columns as output pins.
But my column pins are spread across two ports
That would be one, or two, instructions to set the appropriate port direction registers. Never a loop. Perhaps the author should give Asm a try if he thinks the language is getting in the way.
Times are changing. You can get 32-bit MCUs for under $0.30. This will continue to fall as fabs used for MCUs are 15 years behind the high end stuff, so they have at least that much more Moore's law in them.
You can say that 8-bit ones are always going to be cheaper (you can get some for under $0.03 now) but at some point packaging not silicon costs will dominate. Also programmer productivity is very often worth more than a few cents of BOM cost, which is why we are seeing HLL languages used more often in embedded space even in production settings.
As a side note, the NRF52 listed in article have a 32 bit config register per pin (granted the pin direction is separately mapped as 32 bit word per port).
The same thing was said when C# came out, 20 years ago. "Now hardware is cheaper and software costlier"... "C# is the language of the future and will replace X language (and X++ too)"... "Just add some GB of RAM to the server and that's it, since it costs nothing".
Still, we see everyday new articles on HN about new "low-level" or "system" languages, with focus on performance, speed, safety and whatever, on today's hardware. Even today, there is still a need to write low-level, fast software, trying to squeeze every cycle and bit of RAM. And C and C++ are still alive.
> Also programmer productivity is very often worth more than a few cents of BOM cost.
Guess what would happen if I say my boss that a $0.03 MCU can do the same thing as a $0.30 MCU? What would my boss choose? My mental health, or his pocket?
$0.27 x 1000000 = $270000. That's another Ferrari.
Interesting write up. I've written zero Rust and only a few toy examples in Zig. I do write quite a bit of straight C on microcontrollers.
"...every time I use Rust on a new project, I run into some issue that forces me to confront a new corner of the language/ecosystem."
This was the tour de force comment for me. This is how I feel about web programming, Swift, growingly so about Kotlin. I figure you can use Rust as a mutable variable for many of the "new languages" for developers to use.
We didn't have time to write you a short language, so we wrote you a complex one instead. Reminds of laws in Congress these days.
This reminds me of a passage in The BlackBox Framework about Niklaus Wirth's approach to language design:
"Wirth’s philosophy of programming languages is that a complex language is not required to solve a complex problem. On the contrary, he believes that languages containing complex features whose purposes are to solve complex problems actually hinder the problem solving effort. The idea is that it is difficult enough for a programmer to solve a complex problem without having to also cope with the complexity of the language. Having a simple programming language as a tool reduces the total complexity of the problem to be solved."
While this might not be a popular observation around here, about all the design choices that have led us from C to C++20 (and soon enough C++23) have been specifically to address problems of this nature. (Rust may someday be as versatile, but that is not on the immediate agenda.)
There is really no reason ever to even consider restricting yourself to C when coding for a microcontroller. The ability to do computation at compile time is essential in this area, and C is just fundamentally lacking. While in principle you could do your compile-time work in the makefile, instead, complicating your build process rarely turns out well.
Some people insist C++ is about "O-O" notions of inheritance, virtual functions, and heap allocation; others, that it is about STL and std::vector. All such people are dead, dead wrong. You don't (generally!) use any of that in microcontroller coding, yet you routinely draw upon all the most powerful features of the language and library to make code that is maximally short, fast, small, and correct. These features put the type system to work for you, not just checking but actively making code correct.
Debugging on microcontrollers is hard enough with a language that makes bad code easier to write than good code. In modern C++ (as now also in Rust), when the program builds and links, it is more often than not right. The more you help yourself to correctness with powerful language features, the more often this happens.
While Zig has features to do computation at compile time, it lacks features to help make that computation correct. Such features would depend on a more powerful type system than Zig provides. Debugging bad compile-time computation at runtime, in a microcontroller, is no fun.
> While Zig has features to do computation at compile time, it lacks features to help make that computation correct.
Huh? Zig will reject comptime overflows/underflows, and you can write suites of inline tests too if you'd like, so whatever you miss at comptime you can constrain using runtime CI-validation at the location of interest.
As written immediately above, "Such features would depend on a more powerful type system than Zig provides." So Zig offers as much help as it can, without.
This was a great post for one reason in particular: it led me down a huge rabbit hole about Zig, which honestly I knew little about before this. And further, into what is perhaps Rust's achilles heel: compile times.
Zig does look interesting. I'm still wrapping my head around the consequences of comptime but fast compile times and easy interoperability with C (C to Zig and Zig to C) are all positives. And yes I know Rust can compile to a C ABI.
It's also interesting to me that the Zig compiler only verifies if a particular instantiation of a "generic" is correct, not all versions. This makes it closer in some ways to C++'s templates (which, at the end of the day, are little more than string substitution). Like the functor pattern arose in C++ and was made possible by essentially just stringing symbols together and the compiler telling you if you could dereference a type (if you could ever figure that out from 8 pages of template errors in gcc; clang was better).
Rust it seems also generates a ton of IR and leaves the LLVM backend to optimize it away. I found an article talking about the history of how this evolved from the early days of Servo and Rust.
I know there's an ongoing effort to, say, add constexpr like behaviour to Rust. I worry if we'll get to a point where we are with C++, where we constantly bolt things onto it to the point that the language is incredibly complex, forcing people to use a sand and minimal subset.
As Rust is getting more and more adoption, it's slowly starting to move away from “hype language” to “boring language used by normies”, like every other successful languages once did. That's a pretty good sign actually.
Rust took a lot of inspiration from the functional programming world (it has some heavy OCaml inspiration), which albeit very powerful and expressive, also come with a conceptual burden.
Rust is also in the unique position, being low level as well as functional, which means it will always be alien no matter what programming background you have.
In order to guarantee memory and thread safety, it also adds a few restrictions. Some are fundamental (mutable XOR shared, move semantic) others are/were temporary implementation limitations (like lexical lifetimes, or arrays being second class citizens, or the `impl` keyword being usable only in a few cases). In that regard, Rust has become much simpler since it was released 6 years ago, but it still has some margin for progress.
> Rust took a lot of inspiration from the functional programming world (it has some heavy OCaml inspiration), which albeit very powerful and expressive, also come with a conceptual burden.
> Rust is also in the unique position, being low level as well as functional, which means it will always be alien no matter what programming background you have.
Having worked with Haskell and generally being very comfortable with functional programming (whether or not I'm happy about the astronomical complexity of Haskell as a language with extensions is another story) and also having a lower-level background I can tell you that the offerings of Rust as a "functional" language (this is generally so ill-defined that you can call basically anything functional) are extremely slim and that I'd never choose it for any of that.
Tagged unions and `match` expressions might make for a functional language to a lot of people but these things are orthogonal to functional programming. They're nice to have, but to harken back to the original topic; Zig has tagged unions and can `switch` on them to unpack payloads just fine.
Like I said, I think FP is ill-defined but even with that in mind it's a stretch to say that Rust is functional.
Most people, I would hope, would agree that function composition is more an inherent feature of FP and honestly I've never seen a particularly compelling argument for Rust having a reasonably useful solution to that. Stitching together methods is one step, but it only gets you so far and isn't as generally useful as what most ML-descendants would give you.
On the balance i find functional languages to be easier than oo languages, so I highly doubt that functional is the reason for complexity. In fact as an FP programmer, I find it's very possible to write zig in a functional style (imports feel like modules), in spite of the oo sugar that zig structs have been endowed with.
Is it really boring that you have to learn 20 different features to do 20 different things? There is a certain economy at play with feature sets that the author brings up that has nothing to do with "boring" or not; having to learn a ton of different things and always feeling like there's probably some tailor-made thing you should be using for exactly your problem is not boring, it's just less productive when working.
You're missing the point here: I meant “boring” as “it's been six years already, we want new toys”. The more it ages and gains adoption (which really skyrocketed in the past two years), the less “cool” Rust will be.
You cannot be “cool” and “mainstream” at the same time.
I don't disagree about any of what you said here in isolation, but I'm not sure that the complaints that the author has stem from Rust being inherently more popular.
Sure, adding features is generally a sign that you're trying to capture/retain users (I recently learned that weak equality operators were added to JavaScript for this exact reason and Brendan Eich regrets it *a lot*), but at the same time it feels like things could be more cohesive than they are.
But yeah, I agree that hype trains lose momentum when things become more common. With that said, I don't really feel like Rust is popular/common enough to lose hype. Basically no one uses it in production in comparison to the obvious alternatives and it'd be a bit odd for it to lose hype so soon.
> but I'm not sure that the complaints that the author has stem from Rust being inherently more popular.
These complaints made the top of HN today, not 5 years ago (when Rust for embedded was way less mature and convenient), for a good reason. And notice that this person isn't advocating that Rust is too complex and you should keep using proven tech like C or Python for doing real stuff, they are advocating using a brand new experimental language, with a radical new design, whose compiler keeps crashing and which still makes breaking changes every once in a while[1]. This specific article is a really good illustration of the hype train moving on.
> but at the same time it feels like things could be more cohesive than they are.
The Rust team attempt to make the feature as cohesive as possible (and compared to a language like C++, they are doing a pretty good job at it) but nothing is perfect. I'm curious if you have specific examples of features that you don't think are cohesive though. (One example I can think of is the old `macro_rule!` vs procedural macros, but maybe there are others).
> With that said, I don't really feel like Rust is popular/common enough to lose hype. Basically no one uses it in production in comparison to the obvious alternatives and it'd be a bit odd for it to lose hype so soon.
Hype is a multi-level thing: the Rust hype is still growing (it still makes the front page of HN almost every day), but the most avant-garde hackers are moving away from it. That's nothing unexpected.
[1]: I'm not bashing Zig in any way, nobody expect such a recent language to be polished already!
It seems like a lot of the problems stem from the definition of each microcontroller port being a different type. So you can't simply pass P0 or P1, you have to choose at compile time which one it is.
I wonder if there's a better way to do this. Perhaps making GPIO peripherals all the same type, with some sort of feature flag for each pin. It would simplify a lot of things.
From the point of view of an embedded developer tired of C/C++, there's a lot of attractive features in Rust for embedded. But I haven't tried to write more than a trivial project in it. I wonder if this is a fatal flaw or just a problem with how the embedded HAL is defined.
Author here. Both my Zig code and the Rust peripheral access crate model the pins as distinct types, which I think is correct --- the pins have different memory addresses and sometimes (depending on the microcontroller) distinct sets of controlling registers.
The tricky part in Rust is how to make things generic across distinct types.
Zig's comptime lets you sort of "duck type" it (but with compile-time checking that all of the methods exist, etc.), whereas Rust requires that you explicitly introduce a trait and implement trait methods across the types.
The embedded HAL crates do this with extensive use of macros, for example: https://github.com/nrf-rs/nrf-hal/blob/aae17943efc24baffe30b...
This solution makes sense given the constraints of Rust, but there's quite a cost in terms of compiler time and cognitive overhead to understand what is going on.
(Aside: I didn't use the HAL in my Rust firmware, that's a higher layer of abstraction; I only used the PAC crates.)
Instances of types also have different memory addresses - that doesn't mean they're different types.
I think it's a hard problem because every microcontroller is different. A pretty common pattern in C land is to define a peripheral struct (eg I2C, GPIO) which has a 32 bit uint for each register in the peripheral, and then create a pointer to the physical memory address for each peripheral. That means you can write functions which take an I2C peripheral without knowing which one - and so if you decide later to move over to I2C2 it's just a case of changing one variable.
That works because broadly there's very little difference between I2C1 and I2C2, or GPIO0 and GPIO1, in the microcontroller. If they start being very different then you'd have problems with that approach.
Yeah, that's a better way to do it. I've been following rust for embedded with great interest but I'm not so sure the HAL work is really going in a good direction. There seems to be a lot of awkward design decisions going into the interfaces (and to be clear, designing even a simple GPIO interface which satisfies even most users is Hard, let along anything which works for something like a serial port). I've a feeling if I started using it in anger I would fairly quickly just write my own.
Most of them are actually the same and on several microcontrollers you can even just access them as an array of structs, selecting each port at runtime. HALs try to hide these facts which you can only find out about in the hardware documentation
Most GPIO defs are just "this is a struct at 0xAABBCCDD"
Perhaps, but it sucks from a performance point of view. In embedded for a lot of operations you want the HAL to compile down to one or two instructions (GPIO being a classic example of this). Dynamic indirection is proportionally very expensive here (though in embedded a lot of the other costs normally associated with pointers are a lot less, since the memory hierarchy is very shallow).
I have a humble feeling Zig has a super bright future. Simplicity, seemingly perfect C interop,...
As a mere apprentice of 'low-level' programming, I have no intention of learning Rust. It's certainly brilliant though and understanding its underlying concepts is something I aspire to.
I think maybe a good deal of people at the same stage I am will also leave Rust aside as a specialized hard-to-use tool.
Yes, in fact the only reason most people (not all) use it because there is no real alternative. Sometimes it is the only tool on the table (the environment).
Zig is way better at metaprogramming, but doesn't give you great safety guarantees. Rust is better at type checking, but has at least 3 different kinds of metaprogramming to patch over usability/composability holes created by that rigidity (2 kinds of macros and const contexts)
Okay. I was about to write a Svelte compiler in C. I am choosing Zig. Thank you!
I first wanted to choose Rust but I never liked that language. I feel that it inhibits thought process. I was making yet another project with Rust. I didn't continue because I felt if in future 25 people were to work on this, only 5 could actually write code without scratching their heads. Rust surely does solve bugs, but does not let me think with freedom. I am on the Zig train.
I loved Nim and considered it to be my second choice. But something pulled me off. Maybe the GC. I still don't understand though why Nim is not as popular as Go. I mean it has good compile times, good performance and the best part, it compiles to C.
Why these languages and not Go or <insert language here>? Simple. Rust, Nim and Zig have `extern` with a C ABI. People don't realize how big this feature is. Two way communication with C.
Gaining popularity is quite an exceptional event. Nim is a fine language although some choices are quite opinionated but such is the case for Go as well.
D compiles (DMD compiler) pretty damn fast too. Which is exactly faster to compile D or Go depends on the nod in a horse race.
> I loved Nim and considered it to be my second choice. But something pulled me off. Maybe the GC.
I was under the impression that the Nim GC was basically entirely optional. Is that not the case? They also added a bunch of other ways to get basically the same thing, did they not? I remember atomic reference counting and so on being presented as basically a drop-in way, but maybe I misunderstood.
You are probably thinking of ARC which is automatic (not "atomic") reference counting a la Bacon/Dingle [1] via Nim's --gc:arc/orc.
Because some people do not consider any "reference counting" to be "garbage collection, I think "automatic memory management" a more clear term.
Regardless, you can turn off all automatic memory mgmt with --gc:none, though the stdlib won't cooperate much. --gc:arc/orc is fairly practical, though still not quite as bug free as, say, --gc:markAndSweep. Nim gives you many options for many things.
Ref counts have a reputation for being slower than mark/sweep/generational/etc AMMs, but (with a lot of compiler assistance/optimizations) they seem at least as fast (and maybe faster) in practice, at least with Nim which usually performs like C/Rust/etc.
To me, the main issue with ref-counting isn't "speed", it is "correctness" or "completeness".
Ref-counting cannot cope with cycles in data structures. This can be worked around by contorting your code to work around the issue, but is not what I would call an ideal situation (it creates edge cases that more natural code would be able to cope with), simply to work around limitations in the GC (I will claim it's GC, just not as good as other reachability checkers).
Well, I was replying to AMM optionality, not completeness. Nim has ORC to break cycles - ref ct + cycle checker. I believe Python grew the same thing eventually from similar refcount beginnings. So, completeness worries need not push you away from Nim. As mentioned, there are many options.
These techniques still seem to be called "adjusted/qualified ref counting" rather than "garbage collection". So, I believe terminological problems have muddied the waters yet again. Or else my own incomplete description did { to the extent that differs at all. :-) }
Yeah, once you start talking about "not pure ref counting" (that is, ad din cycle checkers and what have you), the cost of ref-counting goes up dramatically.
I was trying to differentiate between "garbage collection" (the memory management family of techniques) and "specific species of algorithms for GC" (where I would class "pure reference counting" as one, and various augmentations of ref-counting as several more, as well as stop-and-copy, o the easier side).
Costs can go up, but do not always. Nim has some `acyclic` pragmas to help. Answers to almost all performance questions include a "depends", but now we are back on "speed" not completeness. :-) Anyway, you can probably just use `nim c --gc:markAndSweep` to worry less.
At the moment I have no direct intention to use Nim (nor do I have any direct intentions to avoid using Nim). But, it is good to see that mark-and-sweep is a possible GC strategy.
As far as "speed" vs "completeness/correctness" goes, in many (maybe even most) cases, if I have to choose between them, I tend to choose "completeness/correctness".
At work, I changed one of our cluster build pipelines from "fast" to "correct" (making it take about 16x longer to complete). Unfortunately, that was as an action highlighted in a port-mortem as one of the root causes of a cluster completely imploding.
Someone just asked almost this question in the Nim Forum [1] (coincidentally, unless that was you!). So, you may want to follow that conversation. (EDIT: It may be more global than you are used to in Zig (or than you need/want), but might also be "good enough", depending.)
Thanks for the link. The asker wasn't me. I suppose I would just create my own types/functions that take actual allocators if I were to use Nim. It's more work than you'd like, but the alternative isn't really good enough.
I do not know enough Zig to be sure, but an oft overlooked wrinkle in custom allocators is the width of pointer types aka labels for the allocated regions. Focus is usually on "packing efficiency" or MT-sharing and such of the allocation arenas themselves, not labels of allocated items. Perhaps you could speak to this?
For example, a binary tree with <65536 nodes could use 2 byte pointers and in-node space overhead might be just 4B and if you had, say, 4B floats as key-values this might be "only" 100% space overhead with 8B per node total instead of C++ STL-like 8B x 3 (parent pointers) + other extra junk overhead. (IIRC, I measured it once as 80 bytes per node in the default `std::set` impl...).
In Nim, one could probably address this kind of situation with distinct types:
type nodePtr = distinct uint16
and overloading `[]` and so on in terms of this new `nodePtr` type.
Since virtual memory dereference needs no extra "deref context", global variables/closures/local context (like an ever present proc parameter) may be needed to fully convert a narrow pointer to a VM pointer (or to otherwise access data). I think that this is all do-able in Nim, the language, but the stdlib has no direct conventions.
You might be able to keep using the rest of the stdlib by swapping out the impl of newSeq or newString to take a named parameter defaulting to some global arena, but call site-specializable to whatever you want and then replacing the `string` and `seq` types which propagate this not quite hidden optional parameter, getting the nice usability/brevity of a global arena with the nice tunability of specialized allocators. A macro pragma might even make propagating the allocator handle to called procs that also allocate semi-automatic (but someone would need to get PRs in to annotate all needed stdlib procs...). I am unaware of any Nim project which has done this yet. So, there could be compiler/run-time bugs in the way or other blockades. And it may not be possible to have narrow pointer types as with my binary tree example (but this may also not be possible with Zig's conventions).
This is all really just a little color on your probably correct conclusion (and a question about how flexible Zig's pointer types can be in its stdlib convention).
EDIT: Oh, and though that Forum asker was not you, you may get better answers than from just me here by making an account there and asking on the Nim Forum.
> I was able to compile it to WASM for a layout engine, build and sell a fast desktop search app (Rust shoved into Electron), and compile Rust to an stm32g4 microcontroller to drive a track saw robot (I even found a typo in the register definitions; the full “hard-mode” embedded debugging experience!).
> Despite all this, I still don’t feel comfortable with Rust.
This was pretty much my entire experience with Rust. No matter how much I used it I didn't feel comfortable with it. I tried it out when it was still pretty young and it felt like a child of C++ and ML. It is just as feature rich as C++ and some people obviously like that. But I just couldn't keep up with it.
I would like to point out something that may not be obvious: he is running both Rust embedded and Zig on embedded AND BOTH WORKED. And we're not discussing that as amazing.
Those of you not in embedded may not get just how stunning an achievement that is from "C works sometimes and nothing else does ever".
And a lot of that has been the Rust embedded guys banging on everything in the embedded space. Language, IDE, debugging, hardware probes, everything.
Even if you're not in Rust, have a cheer for the Rust embedded guys because they are laying down pathways that folks like this can walk through with other languages like Zig.
I'm not sure why you'd choose to write it this way? Should this not be a trait that provides the read_keys method and you have a type that exists for each of your target architectures?
Nevermind that you could actually do this, although you probably shouldn't
I think Zig looks very interesting; I just don't do that sort of programming much so I have a hard time finding an interesting (to me) hobby project to try these languages on. But I see this sort of sentiment with `go` a lot:
> However, I ended up finding these absences liberating — this is where the “fun” comes in.
This just feels like a weird "it's not a bug, it's a feature!" Stockholm syndrome. "I actually LIKE that it doesn't do what I want it to do." Baffles me.
The absence of specific features for solving specific problems makes the language surface very small. Which is liberating in the sense that you don't subconsciously look for the "right language feature" for the current coding problem you're trying to solve.
This sentence from the blog post is key:
"For example, it’s now quite clear to me that Rust is a language which has a dedicated feature for everything."
You always have that nagging doubt in the back of the head that you don't know the language good enough yet. And I never got rid of that feeling in over 20 years of coding C++. Turns out, it's not you, it's the language. Switching to a simpler language like C lets you focus on the problem solving again, not solving language puzzles. Zig is truly a "better C" in that sense, because in a way it is an even simpler language than C (while being much more correct), yet it enables fundamental features that C lacks (like comptime and generics).
> Switching to a simpler language like C lets you focus on the problem solving again, not solving language puzzles
Or, read another way, "with simple language X I have to reimplement the things that language Y comes with"
I guess it's all a matter of what level of abstraction one likes to work with. For me, re-implementing a "Set" implementation (eg: go) yet again doesn't count as "productive" just because I'm typing typing typing more more more.
This my point; at least in the past, `go` did not provide a Set implementation.
Some people assert that they feel "productive" because of all the typing they need to do to implement it, AGAIN. My stance is that HAVING to do that decreases productivity. I'm mis-quoting, but something Knuth said is to think of it not as "lines written" but "lines spent".
I feel that `go`'s recent acquiescence on templates/generics is a bigger version of this, and related to the Stockholm Syndrome I mentioned earlier; this caused much anguish in some, and to me it felt like because having that feature would case people NOT GETTING to type in their bespoke, artisinal implementations of all that stuff that's been solved a million times over. To me that's just baffling.
> Switching to a simpler language like C lets you focus on the problem solving again, not solving language puzzles.
But you’re solving a puzzle in either case. Either with a solution you created yourself (much more rewarding!) or a premade one you pick off the shelf (higher chance of first time success, often less time involved)
Distinction without a difference. Most of us are talking about "Provided by the language-ecosystem or not", not necessarily WHERE in that ecosystem it sits.
I've spent a fair amount of time learning C++ throughout the years (beginning in 2001) and Haskell. I mention these as examples of languages that seem to have no end to them.
Using and learning Zig after about 1.5-2 years is many times more productive because I've already found the edges of the language and I'm able to focus more on my actual task instead of debugging my language knowledge. I say this not only in the context of a work task but for things of every scope.
I personally have never felt like I "knew" Haskell, despite working with it and being able to create solutions in it. I know parts of it and there are big parts of it looming somewhere in the distance. There are language extension names that actually give me anxiety when I see them at the top of source files. I think the same feeling is common in C++ users, though admittedly I don't have my finger on that pulse anymore.
Is it possible to create finished solutions that are shorter and where your solution seems to fit the problem more directly in these languages? Yeah, I guess, but at the same time I'm not sure it's ever finished and the anxiety this kind of thing can cause when using these languages I think is underestimated.
There's a tension that this creates where you have to intentionally limit yourself (and suggest others to limit themselves, see "Simple Haskell") because you feel the weight of all of this complexity on you constantly.
"Maybe if we read this 'Thinking in Types' book we can cut down on the amount of lines in our code base, guys?". Meanwhile people using intentionally small languages are provably super productive and you see a lot of coping mechanisms in the communities with these ever-growing colossi languages.
(With all of this said I'm not entirely sure I'd be sold on Zig if it didn't at least have tagged unions and matching/unpacking of them via `switch`. There are language features that I've grown so accustomed to that I feel like I can barely program without them.)
#[cfg(feature = "splitapple")]
type Port = nrf52840_hal::pac::P1;
#[cfg(feature = "keytron")]
type Port = nrf52833_hal::pac::P0;
fn read_keys(port: Port) -> Packet {}
but I think the whole problem here is that author tried to do what would have been #ifdef in C, but that doesn't get you far in Rust when macros are AST-based and types are more specific than `int` and `char*`.
A more "rustic" solution would be to use a trait, e.g.
trait ReadKeys {
type Port;
fn read_keys(Self::Port) -> Packet {}
}
impl ReadKeys for Splitapple {…}
impl ReadKeys for Keytron {…}
My experience with Rust has been that it's possible to do almost anything, but that doesn't mean it's necessarily obvious or ergonomic to do so, or that anyone else will understand my code when its finished.
Use C! I'm in the process of doing my first "serious" project in 'straight' C (I'm a C++ guy from long ago) and it's taken me a while to get into it properly, but I'm starting to get its philosophy and it's becoming easy.
But also, in the embedded space, it clearly has all the support you could ever want. I'm also gradually falling for Zephyr (https://www.zephyrproject.org) that has all the support for (eg) callbacks, all sorts of low level stuff.
I've renamed my working branch to never-ending-story because that's how I feel about rust, it's not fun anymore.
Often, I do a small PoC which works but then I put it inside bigger project I'm hitting one wall after another. Sure, simple examples work fine but advanced usage almost always break and I don't have any expectations anymore (I will avoid new features next time).
Some examples:
- it's still not possible to write your own smart pointer
which would work with dyn traits, including coercions
- macros have artificial limits when called from other
macros (which is exactly why you can't do a PoC and put it into bigger project)
- borrow-checker is very dumb and even simple things like
x(&mut self.a, &mut self.b) don't work and require you
to do destructures, inners and other mumbo-jumbos.
- often, solution is to write macro, I feel like I'm
writing macros all the time lately
(and macros are entirely different language BTW)
As a fellow Rust learner-and-user, I felt lots of points in the article are spot on.
I recently wrote a custom HTTP framework in Rust and had spent 2 weeks to figure out how to store a collection of async closure so I can call any of them later. The idea / goal was very clear, but it was so hard for me to figure out how to implement it in the language.
Maybe I'm not smart enough for Rust, but I've already used Rust for more than 1 year, and still struggled to use it. I don't know if I should switch to a different language, e.g. Golang.
If you haven't worked with Go you should definitely do it, just to compare. It's hard to imagine two popular mainstream languages further apart in spirit than these two.
Go is so focused on simplicity that you'll be up and running (and have your server working) in like a day, tops. Then you can ponder the tradeoffs from a place of (some) knowledge not hypotheticals.
Great article! I'm impressed by the constant praises of Zigs thoughtful ascetic feature design.
That said,
1. Just seems like the same lack-of-feature/"simple" stockholm syndrome from Go. To each their own.
2. It would seem that the author could take the structure of their Zig application and almost directly port it back to Rust (e.g., their conditional compilation problems go away if you do it at a high enough level)
`inline for` is pretty special, though. I bet.... now someone has written a published macro for it.
> 1. Just seems like the same lack-of-feature/"simple" stockholm syndrome from Go.
I don't think I'd say "lol no generics" to Zig… Though I'd have to try to really see whether I'm missing anything (and I think I'd notice pretty quick, with my love for OCaml). There's a chance I'd miss closures.
I'll respond in good faith, but your tone sounds like you're just low-key dissing me.
He originally wrote it in Rust and solved some of his problems when he rewrote it in Zig in a different architecture. The same architecture in Rust would have solved the same problems.
And no, I would not call your second paragraph a fair take.
The Rust vs Zig discussions seems to echo the dichotomy between the declarative and imperative languages, but at the type level. In Rust (as is common in other static languages) you define the types declaratively, while Zig takes the poorly explored route of defining the types imperatively. The declarative way usually is more understandable, cohesive, composable and tool friendly, but not so flexible and understandable-in-the-small as the imperative one.
After writing a lot of Rust, I recently did a small project with Zig to learn the language.
I'm especially impressed with the C interop. You can just import C headers, and Zig will use clang to analyze the header and make all symbols available as regular Zig functions etc. No need for manually writing bindings, which is always an awkward and error-prone chore, or use external tools like bindgen, which still takes quite a bit of effort. Things just work. Zig can also just compile C code.
Rust indeed can feel very heavy, bloated and complicated. The language has a lot of complex features and a steep learning curve.
On the other hand, Rust has an extremely powerful type system that allows building very clean abstractions that enforce correctness. I've never worked with a language that makes it so easy to write correct, maintainable and performant code. With Rust I can jump into almost any code base and contribute, with a high confidence that the compiler will catch most of the obvious issues.
The defining feature of Rust is also the borrow checker and thread safety (Send/Sync), which contribute a lot to the mentioned correctness. Zigs doesn't help you much here. The language is not much of an improvement over C/C++ in this regard. The long-term plan for Zig seems to be static analysis, but if the many attempts for C/C++ in this domain show anything is that this is not possible without severe restrictions and gaps.
Choosing to forego generics and do everything with a comptime abstraction makes Zig a lot easier to understand, compared to Rust generics and traits. The downside is that documentation and predictability suffers. Comptime abstractions can fail to compile with unexpected inputs and require quite a bit of effort. They are also problematic for composability, and require manual documentation, instead of getting nicely autogenerated information about traits and bounds.
Many design decisions in Rust are not inherently tied to the borrow checker. Rust could be a considerably simpler, more concise language. But I also think Rust has gotten many aspects right.
It will be very interesting to see how Zig evolves, but for me, the borrow checker, thread safety and ability to tightly scope `unsafe` would make me chose Rust over Zig for almost all projects.
The complexity of Rust is a pill you have to swallow to get those guarantees, unless you use something like Ada/Spark or verifiable subsets of C - which are both more powerful than Rust in this regard, but also a lot more effort.
Some smaller paper cuts, which are partially just due to the relative youth of Zig:
* no (official) package manager yet, though this is aparently being worked on
* documentation is often incomplete and lacking
* error handling with inferred error sets and `try` is very nice! But for now errors can't hold any data, they are just identifiers, which is often insufficient for good error reporting or handling.
> On the other hand, Rust has an extremely powerful type system that allows building very clean abstractions that enforce correctness.
I love Scala because of this as well, and put up with the slow compile times and large runtime needed because I very much value that correctness. I get a lot of the same with Rust (and more, like data races being a compiler error), with the added bonus that so many of its abstractions are zero-cost.
The more I build software, the more I want strong type systems that I can lean on to help ensure correctness. Obviously that won't eliminate all bugs, but building reliable software on a deadline turns out to be really hard, and if a compiler can tell me I'm doing things wrong before it becomes an expensive mistake in production, that's worth the added effort it takes to write in a language that can help me in this way.
It seems like Zig is approaching it from the other side: a "better C". I don't really want a better C; I want a Scala that runs with the CPU and memory footprint of a C program. Rust is probably as close as I'll get to that.
Unfortunately I've found that a lot of developers -- especially senior ones -- don't want to learn anything new, and want to keep churning out the same overengineered, overabstracted, exception-oriented, mutable-spaghetti Java code, year after year. Reminds me of the saying that some senior developers have one year of experience, repeated ten times.
> * error handling with inferred error sets and `try` is very nice! But for now errors can't hold any data, they are just identifiers, which is often insufficient for good error reporting or handling.
It's still under debate and I'm personally in the camp that errors should not have a payload, so I would avoid assuming that it's definitely the preferable choice. We already have a couple of existing patterns for when diagnostics are needed. That said proposals about adding support for error payloads are still open, so who knows.
It's possible to create closures already (by declaring a struct type with a method (the closure) and instantiating it immediately after), but it's a clunky solution. We also have an open proposal in this space:
The new compiler is very innovative and a distinguishing feature, but I would recommend giving a higher priority to package management.
A package manager is more or less expected by developers now, and I bet you will see a lot more adoption once it's easy to publish and consume libraries with an official packager manager and online repository.
IIUC the line of thoughts is that the current compiler is too slow, and that it doesn't show on a small project. But if you imagine a big project with 100 dependencies, you'll be really slowed done. The new compiler is faster (I think there are benchmarks in the repo) and can compile debug builds without going through LLVM.
> It's still under debate and I'm personally in the camp that errors should not have a payload, so I would avoid assuming that it's definitely the preferable choice.
How can you argue that not having access to string index where the JSON was unparseable is better than having access to it? I read through issues/2647, and I read through the "existing pattern", and it just seems obvious to me that containing the error information in the error is better than trying to hack it through side channels.
If A -> B, and B returns an error through a side channel, then A can use it and "what's so bad about that?"
But this doesn't seem to scale very well.
If A is refactored to use an intermediate function X, then it just doesn't work. A -> X -> C would mean that...
X cannot use Zig's normal error handling syntax to automatically propagate this error that A is better equipped to handle, unless we're going to further stipulate that A must pass this Diagnostics struct into X which then must pass it into B.
If we now assume that X calls into two fallible functions, B and C, and each of them provide their own diagnostics structs, then X will have to take in two "out" arguments that provide the diagnostics, and every single caller of X will have to provide those two values.
You see how this goes. It just doesn't seem scalable.
Why not take the current design to its logical conclusion of simply having every function return a Boolean indicating whether it succeeded or not, and then require the caller to look into some "out" argument to determine what the error was? Obviously, that would be extremely annoying.
A tagged error union containing the diagnostic error values is just a minor evolution of the current design that brings huge wins for language ergonomics.
For errors that don't need to carry a value, there is no additional cost: they compile and work exactly as they do today.
For errors that carry a value, the size of the error struct is only the size of the largest error value (plus the tag, of course), not the product of all possible error values, so the global error set will never grow to be enormous unless you have some very weird error type. In which case, you can solve this problem by fixing that error type.
So, I'm not deeply versed in Zig, but I have personally argued in favor of Zig's async design, which seems exceptionally interesting. I had not realized until now how limiting the error system implementation was, but I had superficially appreciated how much less verbose it was than Rust's where you tend to hand write these error enums, or use a bunch of code gen. However, errors benefit tremendously from having the ability to supply payloads.
No one is required to act on the payloads within the errors, but no one is able to if they don't exist.
The problem is for all the situations where the error payload isn't needed. You now need to carry around the extra syntactic complexity and/or the extra wasted memory (unless you have a strategy to elide all of this stuff when error payloads are not needed).
I think it's not trivial to find a good alternative to status quo.
There are so many situations where an error payload is either mandatory for properly handling the error, or required to get somewhat decent error output...
Side channels are not really a feasible implementation option.
So you have to resort to maintaining the logical information on intermediate levels, returning a result sum type, or stick to good old C paradigms and use a output param.
Which negates all value that error sets offer.
I understand that it's not trivial to implement, but for me it's a sort of must to avoid ending up with messy APIs.
People are lazy, though. I think if errors can't take payloads, it's inevitable you'll end up with many libraries that don't return error payload information when you wish they did. This will trickle out into software using the libraries, where the end-user will get errors that don't include as much useful info as you'd like. So to my mind, not supporting error payloads in a first class way is contrary to Zig's goal of enabling perfect software. :-)
“wasted memory”... this stuff exists on the stack, right? And we’re talking on the order of like 64 bytes or less in common, practical scenarios?
I believe the solution has been clearly presented to those who are listening, and the downsides are so much smaller than the status quo.
If people on the language team are unwilling to provide developers with the tools to make more robust software because of something like 64 bytes of stack memory... I find that pretty shocking. And yes, I mean that it is extremely difficult to diagnose and fix issues in production software when you only get back a static error value that lacks the dynamic error context that a payload would provide. I’ve been there, done that. Do not want that again.
Such a strong viewpoint (avoiding such error payloads) could at least focus more on the technical aspects of how best to implement the elision of unused error payloads instead of just broadly opposing the concept, since it should be obvious how beneficial those payloads are, and the only question is how to remove them for developers who literally cannot spare dozens of bytes of stack memory. (Which I find hard to believe outside of AVR or PIC microcontrollers, which is probably not the best use case to be optimizing for in a new language.)
Elision is an optimization that can be added later. It’s not obvious that it can’t be done, and there’s no clear reason that it has to be solved first... I just don’t think anyone would actually care enough to implement it once they see how nonexistent the negative impacts are, but it would be a cool optimization.
But, maybe everyone who upvoted the apparently most upvoted GitHub issue on the Zig repo (including myself) is just completely wrong and this obvious solution is actually secretly terrible. It’s entirely possible.
Your comment has not really done anything to help me believe that I’m wrong, though, unfortunately.
But, as I’m basically “no one” in this context, my opinion probably doesn’t really matter.
It's not that the "obvious solution" is so terrible, it's that the workaround (not needed in 99% of cases) is really easy and arguably good architectural practice. You can emit an error/ok union and have the error return the structured information.
Note that this isn't like go's "if err = nil" monstrosity, either.
> You can emit an error/ok union and have the error return the structured information.
I already addressed how you’re apparently giving up all of the benefits of Zig’s error handling system to do this. That level of convention breaking isn’t a good sign for anything. The current convention really is that bad, from my point of view.
The obvious solution’s extremely tiny overhead could “easily” be avoided in the almost non-existent cases where it matters.
I want robust software by default, not software where I have to use side channel hacks to get the information I need by default... but maybe that’s just me. (And everyone else who upvoted that GitHub issue)
Of course Zig programmers would be unlikely to see the issue here — naturally the people left are the ones who don’t see the problem. People like myself who know from past experience how problematic not having error context is are likely to just avoid Zig until it meets our minimum requirements.
That’s not a problem for existing Zig users — it works for them — but it is annoying to people like me who think Zig otherwise has a number of interesting aspects.
You're missing the point. Let me give a direct analogy: In erlang there is a fantastic error system, but sometimes you want to emit an ok/error tuple instead. There is a generally sane heuristic of when you do and don't want to use the error pathways, it's a part of making good engineering choices. Generally speaking it's the case where when you want structured errors you use the tuple.
It's possible that erlang developers are merely internalizing sone pain, but it's also a robust system that people have been developing highly reliable and broadly used (e.g. rabbitMQ) systems architectures in, so the choice can't be all that bad.
Eliding would be done by the compiler where the values aren’t used. That’s whole meaning of the word “elide” in a language context.
From the developer’s point of view, the values would always be there, just unused. The compiler would just make them disappear at compile time if unused.
> You now need to carry around the extra syntactic complexity and/or the extra wasted memory
This quote is from your GP post, and I read this as arguing against having the mechanism for getting this payload in the language (and hence the compiler itself), so my language was intended. Am I misunderstanding?
Ah. I think I see what you were saying now. The word “elide” has different connotations to me than the word “omit”. Zig’s omission of error context is something I agree is problematic.
If the standard library doesn’t do follow that convention, then it doesn’t matter what I do in my code. I won’t get the information I need from the standard library, and third party libraries are unlikely to follow this ad hoc convention. I’m certainly not willing to rewrite the entire world to follow this ad hoc convention.
You make a good point that the standard library could make better use of this pattern to show it off, but the language was designed with this sort of thing as an affordance and there even is a (rough) example in the docs: https://ziglang.org/documentation/master/#Tagged-union
You wouldn't have to rewrite the world, but the language is young and maybe more effort could go into making using this pattern more idiomatic and encourage library writers to do it more often.
Why keep two competing languages in your toolbox? I personally do C++ and Python and will pretty much never voluntarily choose another language from their respective niches... Except I'm looking forward to replacing C++ with Rust (any year now), at which point I'll not choose C++ again.
Because no single language can (nor should) be a good match for solving all types problems. For this reason it's better to be fluent in 5 (or so) small languages than one big language IMHO.
Most used first: C (C99 is different enough from the common C/C++ subset that it counts as its own language IMHO), Python, C++ (up to around C++11), Javascript, and recently more and more Zig. Less then I would like: Go (since currently I don't do much server backend stuff).
PS: forgot Objective-C, for coding against macOS APIs.
>"Less then I would like: Go (since currently I don't do much server backend stuff)."
I do loads of server backend in C++. Never felt that I need anything else for this kind of stuff. Sometimes due to client's insistence I did it in other languages but it was their choice.
Correct me if I'm wrong, but I don't think D has the ability to just import C headers and seamlessly use them without having generated or manually written `extern` declarations?
C (or even C++) functions can be called directly from D. There is no need for wrapper functions, argument swizzling, and the C functions do not need to be put into a separate DLL.
I think Walter Bright achieved this via implementing a full-blown C++ parser in the dlang compiler. A [God-Tier] achievement.
Not quite as seamless as Zig, but dstep is an external program that leverages libclang to do the same thing (and generates a D module for you), as well as e.g., smartly convert #define macros to inlineable templates functions :)
> Choosing to forego generics and do everything with a comptime abstraction makes Zig a lot easier to understand, compared to Rust generics and traits. The downside is that documentation and predictability suffers.
If I understand correctly it also means that Zig will never support inference of generic type parameters. Not a dealbreaker, but unfortunate.
I also wonder how relying on comptime for all these things will affect IDE support. I guess the IDE will need to run these comptime computations a lot.
Are these the early signs of the next great hype cycle? I feel I haven't seen nearly as many "We rewrote XYZ in Rust" articles of late.
That said, Zig does look very cool and I think its design choices make a lot of sense. Rewriting from C to Rust is a way bigger step than from C to Zig and you still get a lot of safety improvements (though not quite as many as the borrow checker can provide). It'll be interesting to see how it evolves.
I was wondering recently if "comptime for()" wouldn't be a better name, because "inline for()" sounds like its main feature is "performance through loop unrolling", but the actual main feature seems to be "comptime-duck-typing through loop unrolling" :)
`comptime for (someslice) |capture| {};` is already a valid expression so it would conflict along with `inline while` being the other form.
The majority of uses of it in unrolling duck-typing cases will most likely be replaced by [1] which allows the same but with `switch` instead. Thus `inline for` will be left for loop unrolling and possibly in use with `inline switch` where it matters.
The problem is that comptime implies a full compile-time evaluation, while when doing an inline for you only want to unroll over a list of potentially heterogeneous elements but the body of the for loop will remain available for evaluation at runtime, if not all vales are comptime known. In a comptime block that would cause a compile error.
As abstract focus for large projects not easily summarized, I would say Nim tries to be "richly expressive" out of the box with tools to make it even moreso while Zig is more "intentionally spartan" with tools to make it expressive.
Maybe more helpfully, Zig is much closer to your "C with better syntax" than Nim. Nim is more like Ada & Lisp had a baby with better/more Python-ish syntax ("better" being subjective, as with most things).
( EDIT: But really all should make their own determinations: https://nim-lang.org )
> My interest was not (and still isn’t) in operating systems, programming language design, or safety (with respect to memory, formal verfiability, modeling as types, etc.).
Absolutely if you're not interested in the safety Rust brings, a lot of the design isn't going to feel worth it. The safety is my favorite part of Rust, though :) Zig is quite explicitly not a safe language.
Looks like the core problem is not rust, but the device abstractions. There is no reason peripherals that are literally the same in hardware should not be the same type in software.
How golang compares to zig? Not using any of them, but from my understanding both of them trying to be "simple". Just curious what is the difference on the highter level
Go gained some simplicity by not having generics, instead simply special-casing the two most common generic datastructures: lists and maps. But in the end, the desire for generics was too strong, and now they're adding them to the language, and losing that simplicity win.
Zig gained simplicity by not having generics, but instead giving you comp-time evaluation, which can do everything generics can do and more (e.g., replaces need for preprocessor, need for minilanguage for build variants).
I do think both languages have similar philosophies and feel, though Zig is much lower level, and seems to have more of a focus on "get things exactly right" vs. maybe more of a "eh, good enough / hacky" approach from Go.
I wonder what a language that tried to combine this focus on smallness/orthogonality with a borrow checker would look like. (Would it even be possible?)
Cool project but I think he's still right - you normally don't want GC on your microcontroller and often you don't want heap allocations at all. Very difficult to write Go with those constraints.
Lots of "serious" microcontroller projects are written in MicroPython, Lua (such as eLua), and Espruino (JavaScript).
I think it's simply an outdated oversimplification in 2021 to say that microcontroller projects "normally" don't want heap allocations at all.
TinyGo also makes it relatively easy to avoid heap allocations because you can change a compiler flag to make heap allocations a compiler error^1, if that's required for a particular project.
Generally higher level programmer here (Elixir), who has been dabbling with Zig lately (goal is to write an Elixir type checker!). It's quite refreshing, interesting, and dare I say it, fun!
I realize it doesn't have the same memory safety guarantees as rust, but what I don't quite understand, is what sort of danger that means I'm inviting these days. If my app doesn't deal with anything particular sensitive, do I need to worry about it too much? Am I inviting trouble for my users?
I read Smashing the Stack for Fun and Profit back in the day, and I know that at one point, poorly behaving low level programs could escalate privileges, reach into other programs' memory and so forth.
But is that still relevant these days? I'd think OSes have gotten better about sandboxing programs and such. I know I've seen misbehaving programs segfault before, which I thought was the OS protecting against wayward memory access.
And "memory safety" really doesn't apply if targeting wasm, right? In that case Zig could really shine, since its great for cross compilation and really has memory allocations handled well, and doesn't need a GC, without the "memory safety" downsides.
I guess my question is how much rust's "memory safety" is kind of an XY problem? In what cases is that actually what I should be asking for, compared to the more general question of program correctness? I can see it in rust's original use case of a web browser, since, e.g., tabs shouldn't access each other, but does it apply to all "system" or low level programming use cases?
Partitioning memory-unsafe code into lots of little OS or WASM sandboxes each running with least-privilege would be interesting, but I think it would get really costly at runtime and at development time, because they all have to have disjoint address spaces one way or another and managing the privileges gets crazy. We don't see people actually doing this in practice (although some WASM people are thinking about it).
You would have to be very aggressive about making the partitioning as fine as necessary, otherwise memory unsafety leads to privilege escalation. For example if memory unsafe code in a single sandbox can download arbitrary PDFs and render them, then a malicious PDF will be able to scan scan local networks and exfiltrate data.
Many organizations have independently converged on memory safety being responsible for roughly 70% of vulnerabilities. So yes, still relevant and still a problem for users.
Why wouldn't you just use C for this use case? There is no memory leak or safety problems in a keyboard controller. C is easy and designed for things exactly like this, generates smallest possible executables, runs as fast as possible, every single chip on the planet has a C toolchain. Zig looks like it may be almost as good as C in this case, but IMO it was madness to try Rust here.
> much of the complexity I’d unconsciously attributed to the domain — “this is what systems programming is like” — was in fact a consequence of deliberate Rust design decisions.
This is something I've thought a lot about as I've started to reach the "hundreds of hours" of working with Rust mark. Besides attributing the complexity of Rust to the domain of systems programming, I think a lot of Rust's complexity often gets attributed as a trade-of you're making for the safety guarantees afforded by the borrow checker. But I think a lot of the complexity in Rust is not related to the borrow checker at all, and is just a result of certain design decisions in the language.
For instance, taking the module system as an example, in general, I can declare a dependency in mu `Cargo.toml` file like this: `"crate_name"`, and then import it into a given source file using the use declaration: `use crate_name`. However there's a special case, where if the crate name uses dashes, like `"crate-name"`, then the compiler will implicitly resolve to that from a use declaration using underscores: `use crate_name`.
Similarly, if wade into an unfamiliar code-base, and I see a use declaration like this: `use path::to::foo", if I want to look for the code for this, it could be in one of three places:
1. The file `src/path/to/foo.rs`
2. The file `src/path/to/foo/mod.rs`
3. Some other file, based on re-export via a `pub use` declaration.
So in order to use Rust effectively, I have to just sort of know about all these implicit behaviors of the compiler, and in my experience it took months to learn enough of these tricks and corners to really just be able to sit down with an idea and start coding in Rust without consulting documentation and examples regularly. And even after that the compiler still surprises me sometimes.
To give another example of somewhat vexing implicit behavior which does relate to the ownership system, just today I had a block of code which looked like this:
let x = some_value;
match x { ... }
x = some_other_value;
match x { ... }
Which compiled fine. And then by commenting out the second assignment of `x`:
let x = some_value;
match x { ... }
// x = some_other_value;
match x { ... } // <-- use after move
suddenly I had an ownership error, because `x` was moved by the first match statement. So here what was really going on is that the compiler was "helping me" using implicit rules to establish that `x` was referring to a different memory location after the assignment. It's an example of how Rust has all these implicit behaviors and overlaid systems which are intended to make working in a borrow-checked context easier, but in practice what this often means is that when you change something in such a way that one of these implicit systems breaks down, it can cause a failure in what seems like a totally unrelated place, which can be very surprising.
I wonder if part of this has to do with the fact that Rust seems to appeal to a certain type of programmer who is attracted to esoteric topics and arcane knowledge, so the fact that Rust is essentially an unlimited well of complexity is more a feature than a bug to them. But I have been thinking a lot about what a programming language would look like which has an ownership system like Rust, algebraic types, and a trait system, but puts a ruthless emphasis on productivity and eschewing complexity.
> suddenly I had an ownership error, because `x` was moved by the first match statement
How do you propose it should work instead? Disallow using match with owned values, so that match never moves? Disallow assigning to a variable where the value has been moved, requiring a new or shadowed variable instead? You could do either of those things, but neither would remove complexity, just move it elsewhere and perhaps cause some new issues. It's easy to complain about complexity, but almost all of it exists for a reason, and hasn't been added just because Rust programmers are "attracted to esoteric topics and arcane knowledge". The implicit behavior you are talking about in this case is the ownership system.
To me it would be conceptually simpler to consider one named variable as analogous to a memory location. So I would not be able to write to assign to a variable after a move, because essentially I could read that as "assign some_other_value to the memory location x", which in this case is already owned by the match statement. It seems here that after the assignment, `x` is essentially being implicitly re-declared as a shadowed variable. I have to think in terms of "can the compiler find a way to make this safe" rather than having a more-or-less one-to-one mapping between the code I write and the machine behavior.
> To me it would be conceptually simpler to consider one named variable as analogous to a memory location.
But that's how it already is! The variable x is conceptually a single memory location within the stack frame. The match statement doesn't own the variable x, it owns its previous contents. It has effectively removed whatever was in x and whatever remains in x's memory location is no longer accessible, but the variable is still there.
> To me it would be conceptually simpler to consider one named variable as analogous to a memory location.
I don't think this will work well in practice. For example, would you want to declare a new variable every time an array is reallocated at a new memory location?
Reusing variables has both ergonomic (don't have to think about and manage new names) and conceptual (x may represent the same "thing", e.g., a reallocated array) values.
And in this particular case, the compiler will very helpfully tell you what went wrong, so it's not like a programmer will have to hunt down the bug for hours trying to understand what happened. The programmer has to learn this once. Can we then call this a complexity issue then?
The examples described here surprising. The first two "issues" are handled quite well in an IDE environment, where jump to definition immediately shows me what I'm looking for. And as I said in another comment, the third "issue" is immediately highlighted by the compiler with a clear error message. I have to wonder if these are really complex design wart in the Rust language that I somehow found quite intuitive or comes from an insufficient effort or misunderstanding of how or why these features work. Rust has other complexity issues no doubt, but I don't feel these belong to that discussion.
> I wonder if part of this has to do with the fact that Rust seems to appeal to a certain type of programmer who is attracted to esoteric topics and arcane knowledge
I find this an unfair take, especially having been a witness to the design process in Rust, where a lot of emphasis and effort is put into coming up with designs that have the right complexity-usefulness balance.
A better way to put it is that Rust matches the values that I care about in a programming language [0][1]. What I love about Rust is that I can rely on it to point out a wider class of mistakes that I'd often make in other languages. Forgetting to deallocate a pointer (C/C++) or using multiple objects when trying to run a synchronized code (Java) often need non-trivial time to debug that ultimately don't teach me anything other than to be more careful. In Rust, I can offload that cognitive load to the compiler. And I find that delightful and satisfying. If it compiled, it is highly likely to be correct, moreso after a refactoring session spanning the entire project. And all this makes the effort in learning whatever complexity Rust has worth it. And I say this with the knowledge that the Rust team is doing their best to address the complexity concerns seriously.
> what a programming language would look like which has an ownership system like Rust, algebraic types, and a trait system, but puts a ruthless emphasis on productivity and eschewing complexity.
Do give this a try. It is likely that you'll have to make different tradeoffs. Or you'll discover novel ideas. Either ways, it'll be a learning experience.
> Rust seems to appeal to a certain type of programmer who is attracted to esoteric topics and arcane knowledge, so the fact that Rust is essentially an unlimited well of complexity is more a feature than a bug to them.
Unfortunately true in my experience. The two engineers I know have advocated strongly for Rust at my company cared more for the technical elegance of their code than the long-term costs of using said code.
I agree. I’ve been developing with Rust since pre 1.0. I call developing in Rust, developing by guess work (or where to put the next clone(), unwrap(), Box<>). It’s nearly impossible to write without the analyzer giving you hints along the way. The only fun I experience is the joy of finally getting the compiler to pass.
So, I’ve seriously been considering designing a language with the same assurances, but with different mechanics. One that is easier to learn and use due to the consistency of syntax patterns used across the breadth and depth of it.
He was pretty upfront with the the possibility that his problems with rust were of his own making. The places he tried to leverage conditional compilation could be charitably described as "creative", and would raise eyebrows even in a language like C - where the preprocessor is relatively unconstrained. I'm not familiar with his project beyond the snippets he shared, so he may have had good reason to effectively ifdef inside a function call instead of any of the more traditional locations.
Every time Zig is mentioned I always have to post a most-likely-downvoted warning:
The creator of Zig has expressed some disregard toward proper security in the current prototypes. The standard library is riddled with DOS vulnerabilities as of right now, and reports for them are closed as "The standard library will be rewritten." or the like.
And generally not being designed with that kind of low level/microcontrollers in mind. There is a variant for microcontrollers (https://github.com/tinygo-org/tinygo), so it's not impossible, but the fact that it is a variant and not just a different compile target already tells us something about the different focus.
TinyGo is a Go toolchain for microcontrollers that uses LLVM, and it produces binaries that are extremely small. A few kB is not uncommon from what I've heard, although I don't have much personal experience with it. For WASM, the smallest binary TinyGo can produce is on the order of 500 bytes, and I would expect bare metal MCU targets to be similarly sized for the smallest hello world binaries.
The standard Go toolchain cannot compile for microcontrollers, so the size of binaries that it produces is irrelevant.
Just like there are many compilers for C, there are multiple compilers for Go with different priorities.
Why do all these recent languages keep using shorthand keywords? Was there a meeting where all language creators decided full keywords aren’t cool? It just makes the code unreadable to me.
really? for a word you're going to write potentially hundreds of thousands of times you prefer to write out "function" because "fn" is not clear enough?
Short keywords improve readability in a big way, IMO. There's obviously a balance to strike but I think things like "impl" "const" or "mut" or "ref" are pretty obvious.
I do prefer function over fn. I use a modern predictive text completions IDE with fuzzy typing, I don’t care if the keyword has 20 characters. I much prefer explicit keywords for readability. I think the struct, trait, impl, and fn relationships and differences are not only weird and intimidating, the shortened names add to the large barrier for entry.
> Despite all this, I still don’t feel comfortable with Rust. It feels fractally complex — seemingly every time I use Rust on a new project, I run into some issue that forces me to confront a new corner of the language/ecosystem. Developing my keyboard firmware was no exception: I ran into two problems, and each required learning a completely new language feature.
There must be a balance between features, security & robustness, speed and easy of use. It all lays down to language design, and I guess we need a new language design paradigm, not a programming paradigm, but a new design paradigm on programming languages
“ Zig is a young project and unfortunately we don’t have yet the capacity to produce extensive documentation and learning materials for everything” - documentation as an afterthought. Thanks, I’ll look elsewhere.
TBF it doesn't make much sense to put much effort into detailed documentation as long as the language isn't stable yet. And what's already there of documentation is more then enough to be productive, at least if you don't mind nosing around the standard library's source code a bit (which is very educational too, so win-win).
Documentation is not an afterthought. There is specific structure for do strings. IIRC Completion of documentation is on the roadmap, it's just in the icebox until a certain set of other features is complete.
Tbh, i think it's a great strategy. As wonderful as it is, zig has enough bugs in it still that you don't want total newbies all in yet, and this acts as a bit of a gatekeeping device (which will be lifted in due time) and also keeps negatively predisposed people "looking for excuses to not use zig" out -- as I'd say works really well in your case!
If you're wondering how to get some adoption without having a killer app written in your language - in this case Zig, there is a second way:
Take programs in an already popular language and transpile the code to your language.
One of my projects py2many is exactly that. It supports 6 languages now for a small set of features. Would love to review patches if someone submitted a Zig backend.
I navigated from the post to Zig's documentation, which the author links to. Seems a bit more than one page, but OK. I checked for recursion, as I value being able to use recursion without stack overflow or maximum recursion depth errors. I read:
> Recursion is a fundamental tool in modeling software. However it has an often-overlooked problem: unbounded memory allocation.
That it not necessarily true. It depends on the implementation in the given language. Perhaps in Zig it has that problem, but it is not an inherent property of every recursion one can think of.
If Zig manages to get optimized tail calls like I can enjoy in Scheme, it has a good chance of being the next language I learn. As noted in the next paragraph of the docs, it is still an area of experimentation.
Correct me if I'm wrong, but I think it's not possible to express all recursion as tail-call recursion. So I think the statement is correct, in the general case.
But, yes, it's true that you can express some recursion with fixed memory size. And, in fact, the @call[0] built-in has an `always_tail` which asserts that the call should always be generated with tail call recursion optimization, and is an error at compile time, if not.
You might also be interested in following this (open) GitHub issue[1] that explores recursion in more detail.
But, yes, this area of Zig seems to be still a little "experimental", as you say. (But shows great promise, I think!)
I think it is possible to express all recursion as tail-call recursion, but only by passing continuations as arguments, which might lose the advantage of a tail-call recursion. Any computation you have left in the current frame you could put into a continuation. However, that continuation then grows, so you need more memory for it, instead of multiple stack frames, so the advantage ist lost.
The phrase makes a statement about recursion in general, no further narrowing it down to one kind. One cannot generalize from one case (non-tail recursive, which might have unbounded memory allocation) to the whole class of calls, which is recursive calls or recursion in general.
So it is not necessarily a true statement about recursion. It is only a true statement about non-tail recursive ones. It is an over-generalization. The docs would to well to distinguish those.
Edit: Tried with different optimization levels, it seems it might. I wonder if it's an optimization from LLVM. Nevertheless, I don't think that Zig is the kind of language where you want to use recursion as your primary mean of looping; it's not a functional language, it's a procedural language.
For a generic led driver, it should not use these types, but instead the trait types from the embedded_hal crate, such as "OutputPin" that is implemented by the different chip-specific HALs. There is an example of a generic led driver that uses these traits at [1].
In general I recommend everyone who wants to try out Rust on embedded to read the embedded rust book, because it clarifies a lot of the reasons and advantages of its approach.
[0] https://docs.rust-embedded.org/book/static-guarantees/typest...
[1] https://github.com/drogue-iot/drogue-device/blob/main/rt/src...