Neat concept. Exploring the difference between what successfully compiles vs. what won't seems like a great way of getting more familiar with the language.
I've written a little over 1,000 lines of Zig at this point and I really like it. I think its key feature is a rich compile-time imperative programming environment ("comptime"). If you can have full compile-time imperative code execution, you can get a lot of the benefits of more complicated language features (like C++ templates) "for free."
In C++ templates are "a language within a language", but even templates with all their complexity cannot solve all of the problems you might want to solve at compile-time, so C++ has been gradually expanding its compile-time code execution features so that more and more of the language is available at compile-time (constexpr, consteval, etc). Zig jumps straight to the finish line by making most of the language available at compile-time from the outset, and in doing so avoids the need to add the complexity of templates in the first place.
Having "slices" as a first class type feels like a general and powerful solution to the problems that std::string_view and std::span are trying to solve.
I am comparing Zig to C++ a lot, which many Zig fans would probably take exception to as Zig does not aspire to be a better C++, but rather a better C. Indeed many key C++ patterns like RAII are explicitly out of scope. But to me Zig bridges the gap between C and C++ by solving many of the problems that make C feel too spartan without jumping to the incredible complexity of C++.
There are a few things about Zig that give me pause. I've noticed that compile-time feels like a very lazy environment, meaning that functions do not seem to undergo full semantic analysis unless they are called from somewhere. You can write a function that compiles successfully, leading you to believe the function is syntactically and semantically coherent, only to find that when you add an actual call to that function, the compiler now flags errors inside that function. This adds some friction to development, because the act of writing a function is no longer a self-contained activity. Writing the function feels more like sketching it; later when you actually call it you have a new set of compile errors to contend with.
I also miss tools like ASAN to catch memory errors. I'm guessing things like that will come with time.
> I am comparing Zig to C++ a lot, which many Zig fans would probably take exception to as Zig does not aspire to be a better C++, but rather a better C.
I was one of those people that started the idea that Zig should be compared to C more than C++. One day I'll express more clearly what I meant by that, but in the meantime I would say that Zig can and should be compared with C++ too.
More generally I think we got to the point where Zig deserves to be analyzed in it's own right and not as a reflection of another language because, among other things, it leads to this kind of misunderstanding:
> I've noticed that compile-time feels like a very lazy environment, meaning that functions do not seem to undergo full semantic analysis unless they are called from somewhere.
Comptime's lazyness is a necessary feature and not an accident. This is how Zig can avoid having a macro system.
More in general the idea is that you are supposed to write tests for your code, which will then ensure your functions get analyzed, and which in turn will produce documentation for your code. This makes testing more core to the development process than it is in other languages.
I'm not saying everyone has to like this approach, but if you stop at the comparison with C, you risk missing how the design of Zig is able to spiral into an new and radical programming experience.
Totally. Zig is neither a better C nor a better C++, but an entirely new (and better, IMO) concept for how low-level programming can and should be done. Zig is about as simple as C and as expressive and powerful as C++, but it doesn't follow either's design tradition (unlike Rust, which is very much in the C++ tradition), and should only be compared to either one by virtue of all three being low-level languages.
> Zig is neither a better C nor a better C++, but an entirely...
I agree with your point because zig has evolved, but I do think that it is important to note at least for historical purposes, that zig was created literally as a "better c", and a lot of decisions are made because of some specific pain point X or Y in c
What is C++ tradition? Is it that one does not pay for a feature unless one uses it? In practice C++ broke with that with exceptions and RTTI. Even Rust does adhere to it since its standard library assumes infallible allocations making one to pay for not relying on it in the form of having to write an alternative library.
In all low-level languages you don't pay for what you don't use; that's not a design tradition but a requirement of the domain. I'm talking about the tradition of a low-abstraction, low-level language that has many features so that, when it is read on the page it appears as if it were high-level.
The standard library of C++ assumes exceptions and that dictates code style that, even if one assumes infallible allocations and disable exceptions, one gets suboptimal code. So even with exceptions off one pays the price either in the form of non-standard library or accepting non-optimal performance. And if one has to prepare for fallible allocations, then the standard C++ is of no use at all in practice.
The fallible allocators require to use exceptions. There is no way to use, for example, C++ containers with fallible allocators and disabled exceptions.
But even with exceptions enabled fallible allocation in practice does not work. There was an article that made malloc to return null randomly. All tested C++ implementations crashed then because they allocated memory when generating exceptions.
> So even with exceptions off one pays the price either in the form of non-standard library or accepting non-optimal performance.
Holy moving goalposts, batman!
The "don't pay..." thing has only ever applied to runtime, there nothing in there about not having to write more code yourself, etc.
(It's also a bit dubious in the first place. Chandler had a good talk about this at one of the CppCon's, IIRC. Can't be bothered to look it up, but it should be on YouTube.)
Lazyness is what allows you to write code that feels naturally coherent but that would be an error with eager analysis. As an example:
switch(build.target.os) {
.Linux => std.os.fork(),
.Windows => std.os.funcUniqueToWindows(),
else => @compileError("feature not supported for target os"),
}
This a simplified example to say that each path that depends on a comptime condition, such as the target OS, for example, feels intuitively consistent but in Zig types can (and do) mutate depending on those conditions and if the compiler were to eagerly check dead branches it would find plenty of semantical errors. In the stdlib you can see how `os` corresponds to a different struct definition depending on the target: https://github.com/ziglang/zig/blob/master/lib/std/os.zig#L5...
This definitely does cause problems though; I want to acknowledge that. For example, right now we have an issue that auto-generated documentation does not include unreferenced globals.
I have some ideas to address this, but it does represent a flaw in the status quo design of the language.
In D, all semantic analysis is performed eagerly (I think), except for templates. D also supports CTFE (Compile Time Function Evaluation), `static if`, `static assert` and a few other language constructs that are evaluated at compile time.
I experimented a bit at how D's documentation generator behaves using these language constructs. Here's a snippet of some D code:
/// some struct description
struct Foo(T) // template
{
/// some alias description
alias Result = int;
/// some method description
Result foo()
{
string a = 3; // this does not normally compile
}
static if (is(T == int)) // evaluated at compile time
{
/// some method description 2
void bar() {}
}
version (Windows)
{
/// some method description for Windows
void bazWindows() {}
}
else version (Posix)
{
/// some method description for Posix
void bazPosix() {}
}
else
static assert(false, "Unsupported platform"); // evaluated at compile time
}
When generating documentation for the above code, and `Foo` has not been instantiated, the generated docs will include `Foo`, `Result`, `foo`, `bar`, and `bazWindows`. This is regardless of platform. The return type of `foo` will be `Result` and not `int`. This clearly shows that the D compiler doesn't perform semantic analysis when generating documentation. When doing a regular compilation and `Foo` is instantiated, `bar` will only be included if `T` is an `int`. `bazWindows` will only be compiled on Windows and `bazPosix` will only be compiled on Posix platforms.
Looking at the implementation, the compiler will generate the docs after semantic analysis and only if there are no errors. But, if `Foo` is never instantiated no errors have occurred so it will continue to generate the docs.
On the other hand, if `Foo` is instantiated (and compiles) the compiler will generate docs for the AST after semantic analysis has been performed and `bazWindows` will only be included if the docs were generated on Windows and `bazPosix` will only be included on Posix platforms. What's weird though, is that it seems `bar` will be included regardless of what type `T` is.
C macros are lazy, but they also operate at the level of lexical tokens instead of the AST, which means you can use macros to generate C code, but you can't really do it in a way that is guaranteed to be safe.
With Zig you can have a function where some or all arguments are marked as "comptime", which means the values for those arguments must be known at compile time. Combined with the fact that types can be used as values at compile time means that you can use Zig to generate Zig functions in a safe way.
I'd appreciate some elaboration on that too. It sounds vaguely similar to SFINAE in C++, but I don't know enough about Zig's compilation model to know for sure.
(I'm vaguely familar with Zig from a talk by the creator about 1½ years ago, fwiw.)
The misunderstanding lies in not realizing how lazy evaluation is an integral part of what makes comptime a useful tool and that removing it would break comptime horribly.
That seems a bit of an overstatement. Calls to sometimes-unavailable functions are but one of the many uses of comptime. It seems entirely possible that lazily-analyzed blocks or functions could be demarcated syntactically (eg. "lazy { }") and that the large majority of comptime evaluation would not need to be inside such a block.
Zig compile-time evaluation even allows to construct an arbitrary type like a struct with a platform-specific members all using the same core language. This beats even Rust macros and in retrospect I wonder why this not used in other languages? Even Lisp ended up with macros with own sub language instead of making the core language more useful for code generation. Or consider Go attempts at generics. Something like Zig approach will fit it more I think than various generics proposals.
"Even Lisp ended up with macros with own sub language..." I assume you're talking about Scheme approaches like `syntax-case` and `syntax-rules`? In Common Lisp, macros are written in Lisp, the same way you would write runtime code. Unquoting and splicing are part of the core language (e.g. they can be applied to data as well as to code).
I was not aware that unquoting in Common Lisp can be applied to data. So it is not a specialized sub language just for macros, but a general purpose template system that could also generate code.
It's really a shorthand for writing lists - sexp's are lists, so can use the same syntax.
Macro's in Lisp are essentially "just" functions where the arguments are un-evaluated - you can use all the same functions etc...
Writing a simple Lisp interpreter is really quite educational - when you add macros and quoting into the language, you suddenly have a realisation at how simple it is.
I agree it's an idea that seems so natural in retrospect that I'm also curious why it hasn't traditionally been more popular. One possible reason that comes to mind is that an imperatively-constructed type is harder to statically analyze.
But on the other hand, even C++ templates are Turing complete and even C++ parse trees can depend on the output of a Turing-complete evaluation. So it is hard to see what benefit a C++-like template feature is buying in comparison.
Not only was it not "more popular," I am not aware of even research languages taking the idea of general-purpose partial evaluation not through syntax macros to the same lengths as Zig (although that doesn't mean there weren't any). It's possible that no one had thought of that, perhaps because type systems, while known to be possibly considered as a form of partial evaluation, are generally treated and studied as a completely separate concept: partial evaluation is a compilation technology while types are part of the theory.
I’m not clear on what you define syntax macros, but the compile time evaluation that is present in Zig (based on the example and documentation I’ve seen) are an almost direct implementation of the multistage computation research done late 90s-mid 00s. The papers and such are typically ML based (with a caveat for the early Template Haskell paper in 02) and the underlying constructs of MetaML are very close to what Zig implements. All this work is retroactively semantically underpinned by Rowan Davies and Frank Pfenning work on Modal S4 logic. I don’t know if Andy based Zig on this research, but if he didn’t, the convergence toward the same theory is a great thing to see.
My current work on a visual programming compatible language also uses this strategy for meta programming, so I’m very familiar with the academic literature. It certainly seems that Zig got this right and is doing well implementing it in a way that is usable and understandable.
By syntax macros I mean expressions that can be referentially opaque, i.e. m(a) and m(b) might have different semantics even though a and b have the same reference. Lisp (and C, and Haskell, and Rust) is referentially opaque while Zig is transparent. Opacity is strictly more expressive, but is harder to understand; Zig strives to be the "weakest" language possible to get the job done.
MetaML certainly does seem to be similar to Zig, although the one paper I've now read thanks to your suggestion (https://www.sciencedirect.com/science/article/pii/S030439750...) does not mention introspection and type functions, and another commenter here suggests that it is, actually, referentially opaque.
Suppose a and b have the same reference. Zig allows something like { const a = 42; const b = 72; ... } which gives these names new references which they no longer share, in a scope. So the opacity is there in some built-in operators; the only question is whether macros can imitate that.
I think Zig's comptime has three interesting features:
1. It is referentially transparent, i.e., unlike Lisp, i.e. nothing in the language can distinguish between `1 + 2` and `3`. This means that the semantics of the language is the same as if everything was evaluated at runtime, and the comptime annotation has no impact on semantics (assuming syntax terms aren't objects and there's no `eval`).
2. It supports type introspection and construction.
3. It doesn't have generics and typeclasses as separate constructs but as applications of comptime.
I think 3 is unique to Zig, but I wonder about 1: is it possible in MetaOCaml, as it is in Lisp, C, C++, Rust and Haskell -- but not in Zig! (or, say, Java) -- to write a unit `m`, such that m(1 + 2) is different from m(3)?
I don’t know. I think MetaOcaml is strict about inspecting Code terms like Code (1 + 2) and Code 3 (as far as I remember it is not allowed). So no, I don’t think you can distinguish between them.
I think (again based on examples and docs) that it is actually multistaged. However, this is not apparent because the quote and splice are semi-implicit. The ‘comptime’ keyword is essentially a the quote operator, ie. it produces what is essentially a compile time thunk, lazily capturing the ‘code’ value of the expression defined under the comptime keyword. This thunk is directly analogous to a value of type ‘code a’ in MetaML. Then there is an implicit splice operator in place any time a value defined under comptime is used subsequently. I say it is multistaged because it certainly appears that one can define a comptime variable for some computation and then use that variable in a later comptime definition or expression. So, it looks like a basic data flow analysis to determine dependency is done on the comptime evaluations and those are ordered implicitly. This order would correspond to the more explicit staging syntax of MetaML and the operation semantics that underpin it.
I've heard of Terra, but I didn't know it had predated Zig by a couple of years; thought they were introduced at about the same time. And yes, there are certainly similarities, although Terra thinks of itself as two interoperating languages, while in Zig there is just one (although there are things you can only do in comptime so perhaps it's more of a difference in perspective).
The term partial evaluation tends to be used for research that focuses on automatic evaluation of the static part of the program. When considered as metaprogramming the research would probably discuss type-checking + evaluation as two-stage evaluation, falling under the general umbrella of multi-stage programming.
Tow issues I have noticed with Zig: not as good type signatures for functions and not as good error messages. I am not sure if those are fundamental issues with Zig's apporach or if they can be fixed with more work.
Take for example the definition of floor(), how would an IDE or documentation generator see which types are supported? This is just imperative code which is executed at compile time and at least to me it is not obvious how to interpret it when generating the documentation. On the other hand in Rust it is almost always easy to see which types can be used where.
Still the compiler has to deal with that. So one ends up with an interpreter in the compiler for very esoteric and hard to write/read language instead of just embedding an interpreter of a subset of Java.
One alleged benefit of Java constrained genetics is that they allow in theory better error messages. Still in complex cases even after over 15 years with genetics in Java the error messages are not that great. Zig to some extend delegates to the compile-time library authors responsibility of producing good error messages. But then the error messages can be improved without waiting for the compiler to improve its reporting heuristics to cover all edge cases.
We have a slightly vague proposal in D to basically construct programs as a library at compile time (i.e. we give you an AST, you play with it and give it back, if it's valid it.gets turned into a type).
D has been making structs at compile time for years now, however.
I would guess one reason it wasn't done in other languages was because people simply didn't have a good cross-architecture JIT compilation resource, and didn't want to write it themselves. LLVM makes this _really_ easy. I realize that Zig is transitioning to an LLVM-optional model now. But, for instance, I've been working on an s-expr language with f-expr application, and this combined with LLVM c bindings allows you to generate arbitrary compile time or runtime code. The JIT portion for compile time code was a single function call in LLVM! I started this a while before Zig came out, but alas I haven't devoted enough time to it over the years...
This is very common in D and D doesn't not have any macro system. It uses regular syntax (more or less). D was doing this way before Zig existed and before C++ had constexpr. Simple example of platform specific members:
struct Socket
{
version (Posix)
int handle;
else version (Windows)
SOCKET handle;
else
static assert(false, "Unsupported platform");
}
Another example is the checked numeric type [1] in the D standard library. It takes two types as parameters. The first being the underlying type and the second being a "hook" type. The hook type allows the user to decide what should happen in various error conditions, like overflow, divide by zero and so on. The implementation is written in a Design By Introspection style and inspects the hook type and adopts its implementation depending on what hooks it provides. If the hook type implements the hook for overflow, that hook will be executed, otherwise it falls back to some default behavior.
One of the small but really good features of Zig that immediately stood out to me was the fact that there are no implicit function or method calls. my.name will never call a hidden function. As useful as @property is in Python, it really makes it hard to reason about performance of code until you dig all the way through every bit of every class and type.
> You can write a function that compiles successfully, leading you to believe the function is syntactically and semantically coherent, only to find that when you add an actual call to that function, the compiler now flags errors inside that function.
This happens to me pretty frequently in C++. It won't compile with a syntax error, but if you don't call, say, a templated function, then the compiler simply can't know that the stuff you're doing inside is nonsense.
I don't consider this to be friction. I generally know what I've called, and what I haven't. I expect dark code to be bug-ridden placeholders with good intentions.
Heh fun: this was inspired by Rustlings, which in turn was inspired by NodeSchool.
Mozilla Berlin used to host NodeSchool and the Rust Hack & Learn evenings. It became a bit of a hang spot, and at some point we had a pretty consistent group of folks who'd go to both. Marisa realized Rust could use something similar to NodeSchool, and started hacking on Rustlings during these evenings — which now a few years later has really taken off!
It's really cool to now see other languages in turn be inspired and follow from Rustlings ^^
I wish these types of projects would identify themselves as koans in the README or something. Makes it much easier to surface exercises like this when googling.
You're absolutely right. My own first exposure to this type of learning resource was Ruby Koans. I believe Rustlings (which was my direct inspiration) also credits RK. I'll update the README with the grandparent attribution. :-)
I love these sorts of resources! I just created an "awesome list" [1] to keep track of resources specifically centered around learning by example. I've only got a few so far, several of them being from this thread. Contributions welcome!
I have regex101 open in a tab any time I am developing a regex :). It's an awesome tool, and I especially appreciate the mini language reference in the lower right corner of the window.
If I wanted, as a complete novice, to learn a lower level language, where would be the best place to start.
I always get confused by all these new languages. Ive heard of: nim, rust, D, V, and now Zig. Where do I start? Should I start with C or C++? Or pick one of these? But which one?
Im currently looking at Lisp because I read it was good for alot of things. I'm also aware of Go and Swift but not looked into them at all.
Also, unlike the other languages you mentioned, Go and Swift aren't low-level languages. Low-level languages are those that give you very precise control over the machine instructions, almost as much as Assembly. Low-level programming is not as widely important as it used to be, because these days the cost of an instruction does not depend so much on what the instruction itself is but on the current micro-architecture state of the computer (e.g. which data is in which level of the cache; which branches are predicted and how) so even machine code is pretty high-level these days, but in some domains, especially memory-constrained environments, it is still irreplaceable.
Depends what you want to do. Starting with C is good, because then you get to learn the pitfalls, and hence understand the problems the other languages solve, and their advantages and disadvantages.
What's your goal? Why do you want to learn a "lower level" language? Do you have experience in "higher level" languages?
I would say that a better approach in your case is to start by saying "I want to write programs to solve problems like XXXX", or "I want to learn a language that lands me a job doing YYYY".
For example, if you want to:
- build network services -> golang
- fiddle with OS level stuff (schedulers, memory management, etc.) -> C
- write high performance calculation/simulation programs -> julia
- get a nice solid base that can cover most of the above and has a promising future -> rust
Nim, D, V, Zig are very niche and I wouldn't recommend as a first "lower level" language (because you'll have a harder time finding answers, and they won't give you clear benefits over the other options in any problem space).
C++ is... huge. Modern C++ is a fine language, but you'll have to learn a lot to be able to handle it.
Im not what I consider a programmer though I can program. Ive most experience with Python, on Linux I use shell scripts but am no expert. On windows I use AutoHotKey, though I know most wont consider it a proper language.
My main aim is really to learn about it. I wouldnt mind having a broad general experience in high level and low level before I decide to focus on anything in particular.
I've started looking a compilers and how they work im working through a book about building a compiler. Im really just interested in whats going on in the background. I always find myself wondering about the implementations of the languages I'm using.
I agree with pron, C is the best starter here. It is simple enough to get your head around rather quickly, and has very defined concepts. The one place that gets people is pointers, but it’s a very important part of software to understand.
C is not “low level”, in the assembly sense. It is absolutely a high level language. But it is the simplest, and the one with the most fine control.
I'm not sure on the specifics of what you mean, but zig comes bundled with and wraps gcc. `zig cc` command directly forwards to gcc (try `zig cc --version`) and zig's build system can be used to build C programs.
Zig currently is able to target quite a bit. There's a good chance you could open an issue and it would get added, or find an existing issue and see where it is on the roadmap.
Zig is still "not production ready," embedded is still rough, and you might have to contribute to Ziglang itself to get any particular architecture working appropriately. That said, yes people are using Zig a little for embedded stuff:
I’m interested in exploring Zig, but the draconian measure to ban hard tabs is pushing me (and many others from what I’ve read online) away from it. Andrew, get off your high horse, allow hard tabs, and we can all join hands and work together.
I think the whole point of picking a language standard is to prevent the "space vs tabs" debate. Andrew is trying to standardize the language formatting issues, similar to tools like `rust fmt`, `gofmt` and `black`.
The "Spaces vs. Tabs" argument shouldn't be what stops you from joining hands and working together :)
I completely agree with you, I love not caring about formatting anymore. That being said, does this mean that zig refuses to compile code containing tabs? Because that seems a bit counterproductive to me.
A great advantage of rustfmt and friends is that I don't really have to care about my editor being correctly configured, I can just code without worrying about style and run rustfmt before comitting. But if the compiler outright rejects poorly formatted code it means that I have to run the format tool every time I want to compile or risk having my code rejected due to format issues. Now I have to care about my style again! It's the opposite of what I want.
I mean it's a very small issue and mainly a bikeshed, but that seems like an odd decision to me.
You have a valid point, but if the only formatting issue you will have is tabs, then running `zig fmt` on your codebase is pretty much a non-issue.
If your project is small, just run `zig fmt` on the command line.
If you have a large codebase, you can just incorporate it into your build process (similar to how clang-format and clang-tidy are used in CMake projects).
But again, if you have a large codebase written in Zig, you've probably already configured your editor to run `zig fmt` on save :)
“Refuse to compile” makes it sound like the compiler is able to but unwilling to compile the code. It could very well be that the parser doesn’t recognize tab as a valid token.
If tabbed indetation not being supported by the stage1 compiler is enough of a reason to not use a language it's probably best for zig to avoid those that would make this type of feedback and vice versa until stage2 and the language are stable (both support tab indentation, they just aren't finished).
I'm solidly in the tabs camp myself I just understand bikeshedding about this class of issue isn't what zig wants to/should be worrying about at the moment while core components are still being shifted around and written. In the meantime "zig fmt" runs as part of my build and life moves on.
I used to fight about prettier (js) gofmt and so on. I just finally found I don't care. It's more fun to just code and watch it all get autoformatted to the project or language standard
The reason hard tab people dislike code editors which convert to spaces is that pressing backspaces during editing removes one space, not all auto-inserted spaces. This ends up being very frustrating (since you need to press backspace 4 times). Most “used” languages allow both. Accept tab as another white space character and life goes on. Compile errors for hard tabs is a stubborn decision.
> The reason hard tab people dislike code editors which convert to spaces is that pressing backspaces during editing removes one space, not all auto-inserted spaces
This is simply not the case for any editor I have used recently.
Any editor worth it's salt will indent with tab and de-indent with shift tab with the cursor at the first non whitespace. Any good editor will de-indent with the cursor anywhere on the line.
I've written a little over 1,000 lines of Zig at this point and I really like it. I think its key feature is a rich compile-time imperative programming environment ("comptime"). If you can have full compile-time imperative code execution, you can get a lot of the benefits of more complicated language features (like C++ templates) "for free."
In C++ templates are "a language within a language", but even templates with all their complexity cannot solve all of the problems you might want to solve at compile-time, so C++ has been gradually expanding its compile-time code execution features so that more and more of the language is available at compile-time (constexpr, consteval, etc). Zig jumps straight to the finish line by making most of the language available at compile-time from the outset, and in doing so avoids the need to add the complexity of templates in the first place.
Having "slices" as a first class type feels like a general and powerful solution to the problems that std::string_view and std::span are trying to solve.
I am comparing Zig to C++ a lot, which many Zig fans would probably take exception to as Zig does not aspire to be a better C++, but rather a better C. Indeed many key C++ patterns like RAII are explicitly out of scope. But to me Zig bridges the gap between C and C++ by solving many of the problems that make C feel too spartan without jumping to the incredible complexity of C++.
There are a few things about Zig that give me pause. I've noticed that compile-time feels like a very lazy environment, meaning that functions do not seem to undergo full semantic analysis unless they are called from somewhere. You can write a function that compiles successfully, leading you to believe the function is syntactically and semantically coherent, only to find that when you add an actual call to that function, the compiler now flags errors inside that function. This adds some friction to development, because the act of writing a function is no longer a self-contained activity. Writing the function feels more like sketching it; later when you actually call it you have a new set of compile errors to contend with.
I also miss tools like ASAN to catch memory errors. I'm guessing things like that will come with time.
Overall I feel very positive on Zig.