Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
Zig's multi-sequence for loops (kristoff.it)
265 points by kristoff_it on Feb 27, 2023 | hide | past | favorite | 291 comments


In C++23 with zip it looks something like:

  for (const auto& [x, y] = std::views::zip(a, b)) {
    /* ... */
  }
A notable difference is that the ranges don't have to match in size, the loop will run until the end of the shorter range is reached.

If it is required for optimization to not check for reaching the end of one of the ranges then it can be achieved with something like:

  for (const auto& [x, y] = std::views::zip(a, std::ranges::subrange(std::ranges::begin(b), std::unreachable_sentinel))) {
    /*...*/
  }
I guess it's hard enough to bump into this by accident.


God, C++ even today is still horribly verbose. I get that being "explicit" is being "better" but there are limits. Even after auto removed a lot of verbosity there still is a lot there just to get it to do exactly what you want.


Here's how it looks when you write readable C++:

    for (const auto &[x, y]: zip(a, subrange(begin(b), unreachable_sentinel))) {
        /* Do something with x and y */
    }
I'm not sure why C++ programmers don't like using `using`, it's as if Java programmers insist on typing out java.util.ArrayList every time because you may have an ArrayList of your own in the future.

So much C++ code can become readable by adding `using namespace std::something`.


The reason is header files. If you do `using namespace std::something` in one file and it gets included in another, the other file now has std::something in the global namespace which it may not have excepted.

Other languages like Java have imports scoped a to a single file, so this is not a problem.


The word "antiquated" comes to mind, but it's worse than that. An age-old self-created fractal hell of dumb problems created by a simplistic view of code reuse based on the simple mechanism of text inclusion that constantly restrains future evolution. It's antediluvian and just plain backwards.


We are at least getting modules real soon now: https://en.cppreference.com/w/cpp/language/modules


We've been getting them real soon for… about 5 years now?


They were supposed to be part of C++11, back when it was called C++0x, before getting punted. So more like almost 20 years now (since some time after C++03.)


Java is still trying to get value types.

Some things take time when trying to avoid Python 2 / 3 scenarios.


The Python 2/3 scenario was because of breaking backward compatibility, which is not the case for C++ modules.


The only compiler that seems to care about implementing modules is MSVC. It'll be "real soon" when GCC stops crashing.


I think there's a GCC branch that implements module, and it seemed pretty stable. But there must be a reason why it's not merged yet so you're most likely right.


Many are quite happy to only use VC++.


I see, but that's only a program in header files, isn't it? Most code will end up in .cpp files which don't get included (usually).

It makes sense to use std::vector in a .h(pp) file, but in the .cpp you should be able to `using namespace std`, right?


Most code will end up in .cpp files which don't get included (usually).

Not if a lot of your code is in the form of templates. You have to put those in headers.


At this point, just use a modern language already d=


Not when using modules.


It should be completely fine to use `using namespace std` inside a function body right ?


Using in a header file => complete no-no.

Using in a cpp file => absolutely fine.


> Using in a cpp file => absolutely fine.

definitely not as soon as you want to do unity/jumbo builds (which are in my experience the n°1 thing to do to get fast CI builds)


C++ doesn't have a lot of namespacing below 'std', so if you go around using everything you need, you end up with a lot of short, possibly conflicting names in scope.

Java avoids this somewhat because functions are always in a class, so there is a little bit of extra namespacing, even if you've imported the class.


Don’t the boost libraries use a bunch of namespacing? And I think you need boost to be decently useful in c++.


You can still import fully qualified static method on a class:

import someclass.doThing;

Or maybe that's what you meant by somewhat :)


In an actual .cpp compilation unit, I'd probably write it like that.

In a header it's generally advised to not add using namespaces.

However, I appreciate the grandparent comment including the namespaces in the example, so I can see exactly where things are coming from.


I see the same thing in PHP. Example code full of inline namespace-qualified paths and a lot of array() instead of [] as if nothing changed since PHP4.


What is verbose about that? (The first case, not the second, special case)

Seems hard to make it any shorter. Well, I guess you could remove "const" if you want.


I don't do C++ but if it's anyting like my Java IDE most of that stuff pops up in autocomplete and you just tab through it all


D has std.zip:

https://dlang.org/phobos/std_range.html#zip

Sorting two arrays in parallel:

    import std.algorithm.sorting : sort;

    int[] a = [ 1, 2, 3 ];
    string[] b = [ "a", "c", "b" ];
    zip(a, b).sort!((t1, t2) => t1[0] > t2[0]);

    writeln(a); // [3, 2, 1]
    // b is sorted according to a's sorting
    writeln(b); // ["b", "c", "a"]


In Common Lisp it's

  (loop
    for x in a   ; on each iteration, steps x to next element of a
    for y in b   ; same thing
    do ...)
This is like the Zig in that it's a hard-coded feature of the looping construct instead of being a general combinator like C++/Rust, but I think it's neat that by allowing multiple clauses, zipping falls out completely for free.


> This is like the Zig in that it's a hard-coded feature of the looping construct

I think lisp’s loop isn’t hard-coded in the language, but defined in the standard library. See for example the loop implementation at https://github.com/sbcl/sbcl/blob/master/src/code/loop.lisp (about 2,000 lines because loop is a monster/very flexible (pick whatever you prefer. I would pick both))


LOOP isn't hard-coded into the language, but the possible clauses are hard-coded into LOOP. This is in contrast to ITERATE, which is an extensible CL looping macro, or the generator/iterator style popular in C++/Rust/Python/etc.


In less than 400 lines of C99 preprocessing (not counting some utilities included), I made a loop macro for Awk which supports parallel and nested (cross-product) iteration, and user-definable clauses.

https://www.kylheku.com/cgit/cppawk/tree/share/cppawk/includ...

The bulk of the file is defining the clauses, each of which requires six lines: six simple macros have to be defined. (Kind of reminiscent of the five SETF expanders in Common Lisp for defining a new place.)

This is thoroughly documented by a man page, including user-defined clauses. An complete example is given how to make a cause which iterates over alphanumeric strings, as in:

         BEGIN {
           loop (alpha_range (x, "aa0", "cc9"))
             print x
         }
where x is stepped over "aa0", "aa1", ..., "aa9", ... "ab0", ..., "cc9".


I'm a fan of Norvig's LOOP implementation from PAIP: http://www.norvig.com/paip/loop.lisp It's missing some things from the full ANSI CL loop but it's just 223 code lines, and importantly not particularly clever code lines so that it serves as great instruction material for a method on how one could write one's own macro as complex as loop is.


It's just a macro around mapc

  ;; the exprs evaluate to lists, and the vars step over them

  (mapc-loop (expr1 expr2 ...) (var1 var2 ...) body)
We want this translation:

  (mapc (lambda (var1 var2 ...) body) expr1 expr2 ...)
If we leave all matters of error checking to the generated code, it's just:

  (defmacro mapc-loop (exprs vars &rest body)
    `(mapc (lambda ,vars ,@body) ,@exprs))
It boggles the mind what that passes for a blog-worthy invention in some backwaters.


As someone who is using a lot of C++20, I can't wait to use this feature when C++23 is finally ready :)


This kind of syntactic sugar used to appeal to me, but now I think it's a pretty weird feature to add to a language. Using zip / enumerate primitives feels a lot more flexible.


Depends on what you mean by flexible. If you want to use them outside of loops then they could cause magic data copies behind the hood. Zig really hates hidden control flow/allocations/copies. Within the for syntax it's pretty straightforward what gets assigned to what variables and how copies can be avoided.

Doing things like `a = @zip(some_list, some_other_list)` can be reasoned about in multiple ways, some of which involve silently calling malloc. It's particularly unclear what could be done with `a` afterwards. Zig hates that kind of ambiguity and is happy to err away from flexibility at times.


Rust also hates hidden allocations, and its iterator system can do all of this without them

Although- thinking about it, that may rely on the borrow checker (move semantics specifically)


That's nothing special though, `zip` just takes an item from each iterator, packs them into a tuple, and yields that. It has no weird bounds or requirements or anything: https://doc.rust-lang.org/std/iter/fn.zip.html

The impl of the default `next` is:

    fn next(&mut self) -> Option<(A::Item, B::Item)> {
        let x = self.a.next()?;
        let y = self.b.next()?;
        Some((x, y))
    }
So completely straightforward.


I was thinking about the fact that whatever you're iterating over has to be copied around throughout the process. Rust can guarantee that eg. deep-copies (clones) of allocated structs will never happen implicitly, if your iterator owns the values being iterated. But in languages where copying can trigger allocations, this could be a problem

I don't actually know whether that applies to Zig though


You're assuming that an iterator gets created and returned by zip. In zig the for loop syntax doesn't need the concept of an iterator. All you need is variables in a capture.

That's what I mean by straightforward. You'd have to argue about the memory layout for an iterator, that you call functions on it etc. Lots of small decisions. In zig those things would usually be part of the standard library, not language features.

If you want to call a next() function on a thing in zig, you want to be explicit. That's what the language means by "no hidden control flow". You'd use a while loop if you wanted to iterate on a hashmap for example.

In some sense, this is less elegant, but it's usually more obvious what the compiler would end up doing in any bit of code.


Can that zip more than two iterators? And does it perform a bounds check on each call to `a.next()` and `b.next()`?


It stops when one of the two iterators ends

It can't zip more than two per se, but you could zip the result of the first zip into a third and get ((item1, item2), item3). You could then map these if you wanted, to flatten them into a single tuple .map(|((item1, item2), item3)| (item1, item2, item3))

Of course there's a trade-off here between ergonomics and generality


> It can't zip more than two per se, but you could zip the result of the first zip into a third and get ((item1, item2), item3). You could then map these if you wanted, to flatten them into a single tuple .map(|((item1, item2), item3)| (item1, item2, item3))

FWIW that's more or less what `itertools::izip!` does for you, it just chains `zip`s then "splats" them using a `map`.


> It stops when one of the two iterators ends

Right; my question is, suppose you're iterating over two slice iterators - won't each call to `a.next()` and `b.next()` have to check whether that sub-iterator is done? One of the benefits of the Zig approach is that you can iterate over an arbitrary number of slices and do one check before entering the loop, followed by the compiler emitting unchecked index access in the loop. So it basically compiles down to the equivalent of a C `for` loop.


Rust's zip has a specialisation for iterators with a trusted length. Such as slice iterators.

`zip` yields exactly the same assembly as a loop over the index range with an unsafe item access: https://godbolt.org/z/7ebfxbhxc


For Zip it's TrustedRandomAccess[0] instead of TrustedLen. Imo the most radioactively unsafe trait in the standard library and will likely never be stabilized in its current form.

[0] https://github.com/rust-lang/rust/blob/f540a25745e03cfe9eac7...


That's cool. At the same time though, it almost feels like a distinction without a difference in some ways - Zig has a special built-in syntax; Rust doesn't use special syntax, but it does use complex special-cased unsafe code in the stdlib in order to implement a safe + performant API.


Rust's is built on top of (and exposed to) Iterators, which are a very general concept that can be rooted in all kinds of data structures, composed in all kinds of ways, and collected/processed in all kinds of ways (i.e. the user's code might not even contain an actual loop). The code continues to work in many situations, even where the optimization doesn't apply

You trade some special-case syntax and ergonomics for that generality, but it is very general even if not all of it is optimized in the same way


On the other hand, the "special cased unsafe code" is applicable to more than just zip, more than just the one array type, and is available in userland (though currently unstable so nightly only, both to implement it on a bespoke type and to rely on it).


You have to pass -O though, the point of Zig's for loop syntax is to get fast compile times and good performance also in debug mode :^)


Interesting, are "trusted-length" iterators something that might ever make it into userspace? Maybe as const generics?


It’s already in userspace, though nightly (and unsafe, obviously), so whether it’ll be stabilised, and in what form, is an open question: https://doc.rust-lang.org/std/iter/trait.TrustedLen.html


You can zip a zipper to combine them, but you end up with an item like (a, (b, (c, d))), not a huge deal since it gets destructured automatically.

The itertools crate has a multizip function/macro that allows zipping as many iterators as you'd like without nesting zips inside of zips!

I really recommend checking out itertools if you use rust and like iterators! It's a direct dependency of rustc itself too, which suggests its a trusted project (it may even be maintained by rust devs, not totally sure).


>Rust also hates hidden allocations

Does it? Rust seems happy to allocate silently all the time.

    let x = String::new(“hi”);
    let y = vec![];
Do either of these allocate? As the writer or reader of this code, how do I know if either of these statements result in a heap allocation, or if the data is strictly on the stack?

Zig’s requirement of explicitly passing around an Allocator type removes any ambiguity completely.


Sure, any arbitrary function (or macro) logic can allocate. It's more a philosophy, not something that's language-enforced[0] in Rust- if you're creating a mutable, variable-size data structure like a String or a Vec or a HashMap you're not going to be very surprised that it allocates at some point (though technically zero-length Vecs don't allocate on construction, they wait until an item is added)

But closures don't require allocation, iterators don't require allocation, async doesn't require allocation. Copy semantics also don't allow allocation- implicit copies can only happen for data structures that are bitwise-copyable, which is enforced by the compiler. For copy-with-allocation you have to implement the Clone trait, and then invoke it explicitly with the .clone() method

But the original context was a question of philosophy, so I was only speaking to Rust's overall philosophy

[0] Technically I think if you're using no_std you won't have access to any standard constructs that allocate (which obviously will prevent their use at compile-time), though I believe you're still allowed to eg. call out to foreign functions manually that would allocate. And of course, this still isn't as granular as Zig's allocation-control.


> Technically I think if you're using no_std you won't have access to any standard constructs that allocate

Indeed, because you only have core, and not alloc, which is where the allocator lives.

Mostly, core looks very similar to the functionality you can get of the same types in std - except that of course types which allocate are missing (Vec, HashMap, Box, String, etc.) and all the types which reflect operating system services are likewise missing (UdpSocket, File, Mutex, etc.)

However there are deviations. For example, Rust's slices in core don't have a sort() method. Why not? Rust provides a stable sort which uses an allocator because that's much faster, it doesn't bother providing a not-so-good stable sort that can work without using an allocator, if you can't afford an allocator but you want stable sort that's a rather niche case, Rust won't solve it. Rust's sort_unstable() doesn't need an allocator and so that is provided on slices even with only the core library.

Rust For Linux, the Linux kernel's Rust, has core, plus its own somewhat customised twist on alloc, plus an entirely custom kernel library. In Rust For Linux, the allocators are all explicit because Linus hates implied allocation. So for example in Rust For Linux a Vec doesn't have a push() method, because push() on a Vec can cause the Vec to grow, which is an allocation - instead Rust For Linux provides Vec::try_push() which fails if you'd need to grow the Vec before it could successfully push.


The Rust docs are very clear that initializing an empty vector (or hashmap) does not cause an allocation and is thus equivalent to using `Vec::with_capacity(0)`. The same applies to strings because strings are vectors of bytes with additional methods. You can also use the SmallVec library to store the first n items in an array on the stack and spill onto the heap once that length is exceeded.

Whether a clearly visible `vec.push(x)` call is silent to you depends on your viewpoint. I'd say it's recognizeable as potential allocation, it has to be when pushing to a dynamic structure of arbitrary size - but I also understand how this can be seen differently. This is, I think, where Rust and Zig differ. Rust makes most expensive operations explicit but still allows things like this or macros, Zig tries to avoid even that.


> Zig’s requirement of explicitly passing around an Allocator type removes any ambiguity completely.

Not really...

    var list = std.ArrayList(u21).init(std.testing.allocator);
    try list.append('a');
Does creating the ArrayList allocate memory? Is appending to the array list reallocates? The only thing you know is that ArrayList will sometime use the allocator, you can't tell when. And this gets worse once the abstraction level increases, if you have your own type which accepts an allocator and has 100 methods it's impossible to tell which methods allocate.

In both languages you still need to know the specifics of the data structures you are working with, the only real problem that Zig solves is to actually allow you to use custom allocators with the std collections, which is something that is still unstable in Rust.


You can tell when, because if it uses the allocator it will return an error. So the first line definitely doesn't allocate, and the second definitely does.

That is, unless you explicitly handle OOM conditions inside your construct, e.g. 'crash if you're OOM', which isn't typical in zig code. All code I interact with will return an allocator error if allocation fails.


For what it's worth (I can't tell if you knew) vec![] doesn't allocate. Actually in this context it likely won't even compile, although it depends.

vec![] says I want a Vec with nothing in it. That doesn't require an allocation. In effect you are asking for three things, a pointer, of the correct type but not actually pointing at anything, and two integers (length and capacity) which are both zero. These three things don't live on the heap, they're a local variable on your stack.

Rust's first question here is: A vec of what? If we wrote something in those square brackets, it could guess the type of that, but we didn't. It may be able to infer the type from what is pushed into this Vec named y later, except we didn't make it mutable so we can't push anything into it - or if it's returned, from the return type (Rust does not allow function parameter or return types to be inferred). If Rust isn't able to infer the type, that's an error during compilation.

  let mut y: Vec<()> = vec![];
That says I want a mutable but initially empty Vec of the empty tuple. Rust is fine with that, and the result is a Vec which can hold up to isize::MAX of the empty tuple. It won't allocate, because the empty tuple doesn't take up any actual space - it's empty. Since empty tuples are indistinguishable in some sense Rust is really destroying them when you put them in the Vec and then making fresh ones to order when you remove them and there's just no way to tell.

  let mut y: Vec<u8> = vec![];
Now we're making a mutable, initially empty, Vec of bytes. This still doesn't allocate... yet. However if we try to push a byte into it, or we ask to ensure there is space for one or more bytes in it, that will allocate.

  let y: Vec<u8> = Vec::with_capacity(1);

This allocates immediately. Even though we promised we won't actually mutate this Vec, we insisted it be created with capacity for at least one byte, which will mean an allocation. Rust may end up allocating for a modest capacity larger than one byte, since it's likely the underlying system works in larger "chunks".


(You’re forgetting a “new” in the string example)


Thanks, I fixed it :)


In general Zig foregoes syntactic sugar and requires implementing higher-level APIs by composing primitives. But a new language feature is a candidate when it solves a use case that can't otherwise be solved, or opens up a path to more efficient code.

Loris' blog post points out that the new for loops address the latter:

> In the multi-sequence for loop version it’s only necessary to test once at the beginning of the loop that the two arrays have equal size, instead of having 2 assertions run every loop iteration. The multi-sequence for loop syntax helps convey intention more clearly to the compiler, which in turn lets it generate more efficient code.

It also builds on existing properties of slices/arrays, rather than adding a new "enumerate primitive".


This is my take as well. The older and more travelled I get the more I disdain these kinds of things. Your language syntax should do whatever the "thing" is that your language model is all about. Syntactic sugar should be for the things you do LOTS.

I watch language after language add sugar to maintain the appeal of their product, one niche group or application at a time. It turns into a death by a thousand cuts, or by a thousand sugar cubes. Most languages start out simple and appealing and understandable, an increasingly short amount of time later, they've layered on "helper" after "helper" to the point it takes a bit of expertese to consume the language effectively.

I dream of a world where we'd measure languages by the complexity of their ASTs rather than their popularity on a TIOBE or StackOverflow index.


Arguably this change to the zig language is overall a simplification because the loop index capture is no longer a special case.


Could be. I think you're more the expert here than me? :D

To me, the followin is a bit of syntactic sugar that I think is the kind of transcendental "go big/basic with it" that I hint at.

Some time ago, I worked in a language that had this idea that any composable block of code could be captured as 0-N statements between the characters [ and ]. They thought they were being clever and called it a "block of code". Which I thought was cool, because it looked like a block. Pedants called it a BlockClosure. If you wanted to pass parameters to one of these, they used a colon denoted list. So a two arg block might look like

[:a :b | <code goes here> ]

So yay, pass a closure to a service, and it "captures" the values be invoking said closure with arguments.

And then the authors thought, okay, enough sugar for a few days, let's just use this. I mean really really really use this.

You can use a two arg block like that for a zip function of course, but why limit it to iteration? Use it in the standard library to implement the "for each" function. Which when you looked at was just that "how dare they not have a for syntax" while implementation. But because it wasn't embedded in the syntax, you could copy/paste/modify to come up with a filter iteration. Or a reduce. Or a map. Or all kinds of interesting compositions "selectAndCollectAndReject" with 3 closures.

And why stop there? They decided, "let's just do boolean logic with these block things too". So where as most languages has special syntax for conditionals (and once they start, they're in competition with their peers to keep adding more and more of them (do while, case, if, if with N elses, on and on). But they just wrote it like

<condition> ifTrue: [trueBlock] ifFalse: [falseBlock]

Sure they optimized it, but from a linguistic point of view, it was the same thing as above. No new sugar was needed.

Whereas many languages have added sugar for optionals (usually involving ?s), this language, 20 years ago, was doing it with closures already. Someone noticed they could implement the following family of "functions"

ifNil: [nilBlock]

ifNil: [nilBlock] notNil: [:notNilValue | notNilBlock]

ifNotNil: [:notNilValue | notNilBlock]

Sure, not as terse as ? (which some endeavoured to deal with), but the language semantics didn't have to change each time there was a new thing to do.

I'm sure there's a Lisper out there that can write their analog to the above. Because it too, is was one of these "do much with little" langauges.


You're essentially describing Lisp already.


Working on low level performance sensitive code in games, this is something I see in code LOTS.

As mentioned in the article, data oriented design runs into the pattern of wanting to iterate over parallel arrays of data frequently.


"Sugar" is implemented by converting an AST into itself, so it wouldn't change its "complexity" at all.


It isn't more flexible. Maybe more expressive or convenient, since you are only describing the flow in a cursory manner.

Sometimes you will need to be explicit about what you want to happen, and then a proper loop facility will let you do that.


To me this looks a lot like closure syntax w/ non-local exits. Seems quite reasonable for a functional programming language.


I think it matters what your target use cases are. This makes me think quite a few people are running ECS systems.


How cache-friendly are zip/enumerate implementations? Zig is influenced by the ideas behind Data Oriented Design, mentioned in the article (and a buried lede, if you ask me). Explicit for loops like this are generally cache-friendly and ideal for eg game programming, as shown in the structs of arrays example.


I tossed together a simple function using enumerate https://godbolt.org/z/PKsEdKvKK

You get the same exact asm as the manual loop.

Of course, the idiom recognition seems to kick in, in both cases, as there's no actual loop here. I tossed in a +sum, which makes that fail, so you get some loops, check it out:

https://godbolt.org/z/1ddf5ded7

They are one instruction different in length, which is kind of amusing to me. Some small differences.


Thanks, that’s exactly what I was asking. I don’t write Rust so it’s informative to see this.


Any time.


As cache-friendly as advancing two pointers and a bounds check.


I don't know how common working with ranges is in Zig. Ruby would iterate on multiple ranges by converting them to arrays

  one = (1..3)
  seven = (7..10)
  (one.to_a + seven.to_a).each {|n| puts n}
I suppose that if it was common they would have added a + method to Range. Actually I think that's possible to implement it with a refinement on the Range class.

Yup, it works. First time I ever used refinements.

  module JoinRanges
    refine Range do
      def +(other)
        self.to_a + other.to_a
      end
    end
  end

  using JoinRanges
  one = (1..3)
  seven = (7..10)
  (one + seven).each {|n| puts n}


This is different. You are concatenating the arrays, whereas the article & discussion are about zipping arrays.


Is there other rationale behind this `for` and `while` syntax? Why not:

   for x in elms {}

   for x in elms, i in 0.. {}
In my view, this seems simpler to read and understand. In particular, the iteration variable is close to the iterated structure. In the Zig proposal you have to think about the position of the iterated structure and the position of the iteration variable.

   for (elms, 0..) |x, i| {}
              ^^^      ^
              Ok it is in second position
I still don't get why `||`. Is this a lambda? Can I write something like:

  fn func(x: i8, i: i8) void {}
  for (elms, 0..) func
In the same vein I do not understand the rationale behind the `while` syntax. Why `:`?

   while (condition) : (i += 1) {


The richness of syntax in Zig has kinda put me off from learning it.

At the risk of sounding like a total ass: when I was checking Zig out (albeit briefly), it reminded me of the "Awful Taste, Great Execution" meme. I remember loving the ideas, but groaning internally when I saw how they need to be used. If someone is interested I could skim docs to see if I can still find an example of what put me off.

Things also seemed arbitrary and inconsistent sometimes, but that could've been a wrong impression since I didn't give it a proper shot overall.

It's a very new language, so I'm sure a lot of it is still in flux anyways. Also, this was all about a year and a half ago, maybe a bit more. So, maybe things improved since then.


Zig is one of the most highly consistent languages I've ever worked with. Often time I think, I wonder if I can... And then I find out that, yes, it does work that way, and, yes, I can. It just isn't often consistent with the way any given other language does things, and often angrily picks a construct/name from another language and uses it totally differently, but honestly, on reflection I often come to realize that my initial anger is ultimately because that other language has been inconsistent or special casing stuff.


I would love to see some examples, as i am not entirely sure what's so of-putting by it. My personal subjective opinion is, that yes, the syntax can in some cases look a bit off-putting (like the || capture) but overall, i don't think it is really ugly.


Because it’s consistent with the rest of Zig’s capture syntax:

    if (foo()) |result| {

    while (it.next()) |elem| {

    bar() catch |err| …


I think that's a valid criticism. The capture syntax and while colon are probably my least favorite aspects of zig.

The capture syntax is syntactically consistent across different constructs that support it, but it isn't semantically consistent. This consistency actually hurts because it's not expressing a general principle across the constructs, rather it's a convenience particular to each construct that we have unified syntax for, giving you the wrong impression the same sort of thing might be happening when it isn't.

The for capture takes elements of whatever kind in an array.

The catch capture only takes errors.

The if capture only takes optionals.

So, it's the same syntax for things that are totally semantically distinct. I get they're trying to make the language "guessable" to an extent and easy to remember/simple enough to fit in one's head, but imo this actually makes it harder to remember. I wind up getting confused because I have to stop to think about what gets captured and what it means etc. It's a writer convenience over a reader convenience. I'd much rather have to remember more, visually distinct syntax for different semantics than I would have to remember special, subtly different overloads of the same syntax across a range of semantics--ironing some syntax into the brain isn't as costly, imo as having to constantly remind oneself how these similar structures differ ever so slightly each time and untangle choices that are pragmatic but not semantically grounded.

All that said, the rest of the language is shaping up pretty fantastically.

Edit: As an example as to how it could be done differently, for null checks go just has the obvious "if (x == nil)". People complain about go's error check verbosity, but imo this is way clearer than zig's approach which requires remembering special magic "unwrap and bind to this only if not null" while saving only a few characters at most.


It is legitimately harder to parse if `in` is a special keyword. Zig's parser is defined in a context-free grammar and having implemented it, trust me the way it's done now is waaaaaay simpler.


Does Zig use `in` for anything else?


No.


Addressing just the capture: I don't see the problem with `||` at all, in-fact I think it's elegant. I do like (most of) Ruby's syntax so that's probably where my fondness of this comes from but I am not a veteran or seasoned Ruby expert by any means.


Agree with the iteration variable being far from the iterated structure.

Also so many special characters for such a simple construct! While I do like {} languages, this has () || {} whereas e.g. Rust would only have {} in that case:

    for x in xs {}
However, Rust's zip is much more cumbersome:

    for (x, y) in xs.iter().zip(ys.iter()) {}
A bit more reasonable with the zip function:

    use std::iter::zip;
    for (x, y) in zip(xs, ys) {}
You still need the () to match the tuple. Would be cool if that wouldn't be needed. And it still removes the connection between multiple iteration variables and their iterated structure. hmmm


I've been curious about Zig. I find its cross compilation story using zig cc interesting. I like its focus on simplicity instead of debugging your knowledge of the language. On the surface it looks like a better C, that isn't as complicated as Rust.

I'll admit though the syntax is a little off putting. But that is a minor complaint. I know it's not 1.0 and there are still lots to do, but I'm curious if they do more for memory safety. With companies trying to avoid starting new code in memory unsafe languages if they don't have to , I wonder if that will hurt Zig adoption. Right now it just seems like their approach is, reduce Undefined Behavior as much as possible, make the "correct" way of programming the easiest, and have array bounds on by default. Will this be enough to make the language "memory safe" enough?


I would suggest you to checkout Odin[0]. It's very similar to Zig but has much better ergonomics and probably the closest to a 'Better C' replacement in my experience. It does array bounds checking by default (which you can turn off if you choose to)

[0] - https://odin-lang.org


Not only is it in the "better C" manual memory category, but is arguably a Jai alternative as well, that is open to the public.


If zig includes tags or annotations (there are a few proposals in the issues tracker) and surfaces this information at an exportable zir level, it seems likely that data provenance tracking (this includes memory safety and file descriptor, socket closing, thread spawn/despawn etc) would be able to be checked by a static analysis system. If zig supports compiler hooks, then it could conceivably halt compilation unless these invariants are satisfied.

I'm not convinced that this needs to be in the type system.

Nonetheless it's not there yet.


> If zig supports compiler hooks, then it could conceivably halt compilation unless these invariants are satisfied.

Can you sketch out why hooks are necessary on the proposals, like 14656 ? So far I have not read a justification, what the advantage of coupling it to the compiler provides as performance gains on concrete examples. Afaik, Rust has lifetime checks decoupled to parallelize compilation and I have not seen research or a list of the possible optimizations with synthetic benchmark results.

> I'm not convinced that this needs to be in the type system.

If you want to prevent static analysis semantics becoming an incompatibilty mess like C due to 1. unknown and 2. untrackable tag semantics, then you have to provide a at least an AST to ensure annotations have known + unique semantics as I explain in https://github.com/ziglang/zig/issues/14656#issuecomment-143.... I would say that this is a primitive quasi-type check decoupled from main compilation and could be kept very flexible for research experiments by having an uri + multihash (so basically what Zig uses as package).

More concretely: Stuff to look out for is RefinedC and related projects to have more strict C semantics + how to compose those things outside the regular Zig type system.


The big advantage to coupling to the compiler is that it gives a tighter feedback loop for the developer, versus, say, statically checking as a part of CI, or forcing the dev to manually trigger static pass


At some point you would have to choose for which analysis you want unless you force the analysis onto everybody. Which means adding it into the build system. Which means separate commands.

The nice thing is that Zig conventions make things just work, because you could choose the command for your code base and third-party code would not need to care about your chosen build commands.

The "binary lsp" will give the necessary info of what has changed etc, so the info is (eventually) incremental with minimal overhead (only ipc + optional network latency).


Why not support both? On the compiler side all it requires is a mechanism to register a subscription and publish to it. This is not terribly hard IMO.


I would guess there will always be a niche for languages that make C's overall set of trade-offs. Rust will shrink that niche, but it'll still be there. I see Zig as targeting that niche specifically: "we can strictly improve on C in a whole bunch of ways, without changing the fundamental bargain"


There is a market for statically checked c, as evidenced by the existence of something like SEL4 (though to be fair it technically checks assembly code)

It seems like statically checked zig has a chance to be strictly easier to implement relative to statically checked C, and that's I think something to shoot for, especially since it could require very little on the part of the zig developers proper.


> ...but I'm curious if they do more for memory safety. With companies trying to avoid starting new code in memory unsafe languages if they don't have to, I wonder if that will hurt Zig adoption.

I'm of the opinion that such companies or persons that would use it, are not as concerned with memory safety. Probably their use cases are more specific, limited, or narrower. Else they would likely go with more convenient GC using languages. Of which, various such languages are also compiled with optional GCs that can be turned off (Dlang, Nim, Vlang). That, or deal with the greater complexities of adopting Rust or Ada, for primarily attempting to gain such greater memory safety. Per that part of your comment, do think the adoption will be smaller, more niche, and/or we will see it hit a lower ceiling.


Anecdotally, I really enjoy C (and Python and my Forth implementations and a few things), and Zig is 10x better for the sorts of things I like about C (some of the syntax, especially the implementation of explicit casts, is a bit verbose for my taste, but it's a small price to pay). Talking about UB and memory safety though:

- The happy path in Zig encourages memory-safe programming and has some tools (runtime checks by default, "Release-Safe" builds with those same checks) for validating reasonable behaviors.

- The (explicitly cultivated) culture around constructs like Allocators helps one be very aware of the memory they're using and what they need to free.

- Conventions of the form `try Obj.init()` and `obj.deinit()` act a lot like RAII and further emphasize the happy path in ziggish code.

- Directly contrasting the rust I write for $WORK and the zig I write for me, especially when the actual memory layout matters (e.g., pick your favorite performance metric) I don't have a substantial difference in the memory safety bugs I write in either language. Rust is actively trying to improve that story and reduce the need for unsafe blocks for atypical constraints, but IMO it's not good enough yet.

- Zig has some shocking footguns that combined with the current pre-1.0 state of code and documentation can introduce issues you probably would not expect. E.g., most meaningful uses of async frames require them to be stored somewhere, but applying the same coding techniques you would in most Zig code and adapting the docs to do so you'll write something that creates a frame and puts it somewhere. Doing so will actually work for small examples, but as soon as that frame does something with a pointer to anything in its stack it'll blow up in production because it's retaining a pointer to the old memory you copied from rather than the new place you copied it to. There are easy solutions, but without in-depth knowledge about exactly what's happening (which is hard to obtain from the current docs) it's hard to realize you would have to find a solution (admittedly, the Zig Discord is __amazing__ at quickly helping people with such issues, so it's not necessarily a huge deal since it's solvable that way now and probably solvable with better docs post-1.0).

- I've only said anything about single-threaded memory safety. Rusts compile-time reader-writer locks are next-level when working on nitty gritty concurrent code. I've hacked together some interesting parallel Zig projects, and I'm like 99.9% sure they're correct now, but it was an adventure to even get it that far, and I'm still not 100%. I haven't been actively following Zig news for a couple months, but last I heard they're planning something important with async/await to improve this story, but it's not there yet.


While this solves one of the issues I've had with Zig, it doesn't seem very flexible. I would love to the same thing for (tuples of) variable-length arrays, arrays of different lengths etc. Now my implementation will still look completely different in slightly different situations.

Yes, a flexible solution (a zip function) would probably need iterators but why would introducing them be such a big problem? (FWIW, I know that one can emulate looping over iterators with while() and optionals[0] but it feels a bit dirty.)

More generally, my biggest gripe with Zig has been a lack of expressiveness: Things like deep equality checks between structs, arrays and optionals; looping over different kinds of containers; … should just work™. Sure, I understand that Zig doesn't want hidden control flow but OTOH explicit control flow everywhere often just gets in the way of readability and of implementing business logic. I usually follow the approach "Implement first, optimize later" but with Zig the implementation will look completely different depending on which optimization or data structure I choose, so I need to think about optimizations from the start if I don't want to rewrite everything later. I should mention, though, I'm very used to Python these days and haven't written C or C++ in ages, so maybe that sort of culture shock is somewhat expected.

Anyway, I'm still excited about the language and my impression is that Andrew Kelley is very open to new suggestions and new ideas, so things will certainly still change in one way or another till v1.0.

[0]: https://news.ycombinator.com/item?id=34958051


There is no "just work" for deep equality. The standard library would have to make decisions on behalf of the application that it has no business making.

I can tell you right now that while there will be many upcoming language changes, none of them will be comfy to Python programmers. Zig is very much an imperative language. Or perhaps think of it as a declarative DSL for outputting machine code.


Thanks for your message, Andrew!

Just to be clear, I didn't mean any offense and maybe my critique wasn't as well-balanced as it could have been. So far, coding in Zig has been a fun ride, despite the occasional obstacles I've run into!

> There is no "just work" for deep equality

Say I have two variables A and B of the same struct type. Each points to a finite region in memory of the same size. Why can't I just compare these two regions using `A == B` to make sure they are equal? Yes, one can obviously come up with alternative definitions of what equality might mean for structs (only compare certain subfields etc.) but wouldn't the aforementioned definition be a good default that would work in the majority of cases?

Alternatively, there is `std.testing.expectEqualDeep()` which walks through all fields but as far as I know there is no equivalent for production code(?)

> none of them will be comfy to Python programmers

Oh I think the multi-sequence for loops feature already makes Zig more comfy! :)

Just to be clear: I wouldn't want Zig to be another Python. While I like Python from a developer experience POV, it is dictionaries and magic methods all the way down and often unnecessarily slow and complex.

I still think one could find a good balance between DX and full low-level control, though. One could e.g. have one convenient way to express a certain problem that gives you medium control over performance (e.g. the `==` example above) and one or more fine-tuned, but possibly less concise ways of expressing the problem that provide full control but require more lines of code. In the struct equality example the latter would mean defining some kind of `eql()` function that would be optimized to the struct type (e.g. compare certain fields first as they are more likely to differ etc.). Would this violate the Zen of Zig?[0]

> Only one obvious way to do things.

After all, there is also

> Favor reading code over writing code

Right now, at least, I often run into situations where I don't know of any obvious way to solve my problem. Then I end up writing lengthy code to tell Zig what I want and end up with code that's so-so on the fun-to-read scale.

[0]: https://ziglang.org/documentation/master/#Zen


> Why can't I just compare these two regions using `A == B` to make sure they are equal?

Why is shallow equality useful?

You could have `A == B` be true, but then as soon as you wrap pointers to them in C and D, now `C == D` is false.


It gets into even more fun if A has a pointer to C has a pointer to B, and B has a pointer to D has a pointer to A.


> > Why can't I just compare these two regions using `A == B` to make sure they are equal?

> Why is shallow equality useful?

Of course deep equality checks are useful, too, I'm not denying that. But in many cases you are not dealing with several layers of structs nested through pointers. You just want to compare simple structs and be done with it, without having to write an eql() function for every single case.

Besides, many other operations in Zig operate directly on the memory, so why should == be any different?


> a declarative DSL for outputting machine code

and thanks for the laugh


I need to correct myself: I only realized now that in my original message I put "deep equality" where it had no business of being. I meant to write "equality" or "shallow equality", i.e. simple comparison of the memory regions the variables represent, as outlined in my other response to you.

My apologies for the confusion!


Deep equality is easy as long as the user has properly implemented it for all the types involved. Standard libraries implementing deep equality correctly on standard collections should really not be a problem at all.


> I need to think about optimizations from the start if I don't want to rewrite everything later

Deep equality checks don't prevent the need for minor rewrites in other cases, like when you change a widely used type's definition.

Rewrite code during optimizations or rewrite the project once Python isn't fast enough? I don't know, both can be very cumbersome.


> Ranges can only exist as an argument to a for loop. This means that you can’t store them in variables

I am confident this is a mistake. Every time you make a new kind of "thing" in your language somebody will want to do all the same stuff with it that they did with the other things, such as integers, ie in this case store a range in a variable. Ideally you'd just always be able to do that, see Lisp, but it can get very unwieldy, thus this is a reason to avoid making new kinds of thing so the issue doesn't arise.

C++ chooses to actually do the heavy lifting here, which is why std::format (and its inspiration fmt::format) was such an enormous undertaking -- C++ can express the idea of a function which takes a variable number of arguments and yet all those arguments are independently type checked at compile time, not via compiler magic but just as a normal feature of the language. This is an enormous labour, and because they don't have any way to fix syntax issues the resulting problem accumulate forever in their language so I cannot recommend it as a course to other languages. It's like the Pyramids, do not build giant stone tombs for your leaders, this is a bad idea and your society should not copy it - however, the ancient Egyptians already did build giant stone tombs and they're pretty awesome to look at.

Anyway, Rust chose to make its half-open range type std::ops::Range an actual type which you can store in a variable, pass to functions, modify etc. as well as using it in a for loop. Obviously don't copy Rust here exactly, for one thing Range should probably be IntoIterator, not an Iterator itself if they had it over, but you will wish this was an ordinary type in your language, so, just do it now.

  let a = 0..4; // The Range starting at zero and (non-inclusively) ending at four.


The problem is that zig's designers apparently don't want to introduce an iterator abstraction, hence the frankenstein-ing of the for loop instead.

Though in fairness getting an iterator abstraction to the same efficiency as a for loop requires pretty brutal optimisations, frankensteining your for loop, a lot less so.


> Though in fairness getting an iterator abstraction to the same efficiency as a for loop requires pretty brutal optimisations

How about Nim's inline iterators?


I don't know how ranges are implemented now (and I'm too lazy to check right now) but It's entirely possible zig's ranges could wind up as comptime-only values.

Then you could pass them around, but only at comptime, which will achieve many of the things you expect.

There's also nothing stopping you from creating an iterator interface in userland.


> There's also nothing stopping you from creating an iterator interface in userland.

I don't see any way you could do that with these ranges, and I'm unsure to what extent you can do this for any of the built-in types without their co-operation. If it needs co-operation then there's actually a lot stopping you from creating a useful iterator interface.

Also there are a bunch of plausible iterator APIs ranging from a very narrow API like Rust's Iterator trait to something as broad as C++ "iterators" which are effectively just (sometimes magic) pointers.


> Though in fairness getting an iterator abstraction to the same efficiency as a for loop requires pretty brutal optimisations

That's the role of the compiler and if Rust or C++ are anything to go by, it is more than capable to "see through" iterators.


Yes, this makes debug builds bloated and slow.


Ranges for for loops was a long time coming. Some C programmers just completely lost their shit at having to use a while loop for some reason. It was such a common complaint that we invented stuff like the following as a joke, that of course became something people actually used because internet gonna internet:

  for(@as([10]void, undefined)) |_, idx| {
    _ = idx;
  }
Which, for those unfamiliar with Zig, has `for` iterate over an 'array' of 0-bit values and capture the index while throwing out the value (which would always be 0 because that's the only value a 0-bit type can represent).

The implementation of this proposal brings with it the additional benefit of making index capturing less mysterious.


I suspect the real reason behind "some C programmers losing their shit" was being forced to introduce a new variable into the outer scope, or to use extra braces around your loop -- both are distasteful hacks, according to some.


Regarding the lengths must match:

> (i.e. you will get a panic in safe release modes)

Should I take that to mean there is an unsafe release mode without the bounds check? But UB is mentioned too. Is there UB!?

It's 2023; I think we can afford a single branch to avoid UB, even in release mode.


> Should I take that to mean there is an unsafe release mode without the bounds check?

Yes, it's called ReleaseFast.

> It's 2023; I think we can afford a single branch to avoid UB, even in release mode.

Zig has 3 release modes: ReleaseSafe, ReleaseFast, ReleaseSmall. If you want the safety checks, just use the first.

It might even be $currentYear, but many of the latest AAA games still don't always run at more than 60fps on my fairly powerful machine and I sincerely hope they were built with all the optimizations enabled.


> I sincerely hope they were built with all the optimizations enabled.

Sure, but I don't agree that disabling safety checks is an "optimization". It is a regression in functionality that is betting on nothing going wrong.

Bounds checks do not cost much[1]. Maybe if a bounds check disables vectorization[2].

[1] https://blog.readyset.io/bounds-checks/

[2] https://github.com/matklad/bounds-check-cost

There are a lot of techniques to remove bounds checks, e.g. in counted loops [3][4].

[3] https://ieeexplore.ieee.org/document/5381765

[4] https://en.wikipedia.org/wiki/Bounds-checking_elimination


btw note that you're arguing this point in a the thread of a blog post about a feature that is all about maintaining safety while not paying for it at runtime. There's an entire section dedicated to explaining this point.


If that's what you believe, you are free to enable them as the programmer. Programmers who disagree are likewise free not to.

This can even be decided on a scope-by-scope basis if so desired.


If it's a program you wrote to run on your on hardware, feel free. But in reality most programmers write programs for other people's computers, or just write programs because it's fun or pays well, and then their work gets integrated into a larger whole at a much later date, and then it's run in contexts the original author never imagined, long after they move on. Safety checks catch the base-level logic bugs that would otherwise cause programs to go silently wrong and misbehave in complex and inscrutable ways. Disabling them is not just living dangerously, it's a moral hazard; the programmer doesn't suffer the consequences, users do. It's not your program or computer at risk, but someone else's. I don't know how as a profession we're so cavalier with shipping exposed whirling knives, but we are.


> the programmer doesn't suffer the consequences, users do.

The same is true of poorly performing programs. My computer's resources are not the programmers' to waste, yet they routinely do waste it to save themselves time[0].

> I don't know how as a profession we're so cavalier with shipping exposed whirling knives, but we are.

That's a separate problem than not handcuffing programmers and forcing them into safety checks. Why should Zig force this?

Like, I just don't even get what you're complaining about here. The default build mode and the recommended release one insert the check. Checks can additionally be enabled and disabled on a scope-by-scope basis. What exactly do you want? Just eliminate ReleaseFast as an option and give people more reasons to go back to footgun-laden C because it'll be the only way to eliminate a bounds check in a tight loop hot spot?

[0] Yes, I know this isn't due to safety checks in the vast majority of circumstances, that's not the point. I have nothing against safety checks, my problem is with the mentality that it should not be possible to disable them. Even Rust has `unsafe`.


the mere naming of the keyword `unsafe` has been a wholly unintentional disaster for programming in general as more and more people use Rust, because "safe"/"safety"/"unsafe" are sort of emotionally-loaded words in English, and it's led to people to build mental heuristics about the pros and cons of "safe" and "unsafe" code which may be subtly incorrect. the language feature itself is completely reasonable of course, given the design decisions of the language, but as Andy said elsewhere in this comments thread:

> Rust evangelists need to be careful because in their zeal they have started to cause subtle errors in the general knowledge of how computers work in young people's minds. Ironically it's a form of memory corruption.

I'm not even a zig user or fan or anything and I don't have any real opinion about Rust, either, except for completely agreeing with this analysis based on how I've seen Rust evangelists talk online. I'm not sure what the solution to this is, but it seems like it's just going to get worse over time as Rust becomes more popular and gains market share.


The term "memory safety" is much older than Rust and very common, the "unsafe" keyword is based on that existing concept and I think that consistency is the right choice here. I also don't have the impression that this is communicated in a way that leads to confusion with "correctness".

What alternative name would you prefer to express the collection of memory safety features in programming languages?


If we go so far as to say that using anything unsafe is dangerous and a "moral hazard" then we would also have to disqualify Rust, C#, and any other language that allows unsafe escape hatches (especially in dependencies).


So isn't it on the programmer to ensure the safety checks are enabled if appropriate? I agree with the gist of your statement, I'm just not sure how this is the responsibility of the language itself. It ships with the option to build via a safe mode. I don't think it's a moral imperative of the language designer to ship without an unsafe mode. Even rust has unsafe blocks.

In most engineering professions, it's the engineer's responsibility to ensure appropriate levels of safety, not the CAD software used to build the blueprints. But every situation doesn't have the same level of safety required; backyard sheds don't have the same needs as skyscrapers.


Most engineering disciplines are considerably more regulated than software development, and for good reason; bridges and skyscrapers falling down can kill people. Even electrical engineering and device manufacturing have to fit in with standards that address shock hazard and EMF interference.

I actually do think it is the responsibility of the language and runtime system to ensure some base-level safety of programs. The one constant over the years is that programmers keep making mistakes. No matter how much they keep yelling "trust us", they (we) just keep screwing up. That's not to pillory us programmers. It's just the facts that everyone screws up. In some sense, engineering is putting processes and procedures and checks in place that move human fallibility out of the critical load-bearing situations so that a simple whoops or memory slip doesn't kill people or ruin things.


Without bounds checks: game crashes, core dump.

With bounds checks: game crashes, meaningless error message given to the user.

What am I missing?


With bounds checks: game crashes, meaningless error message given to the user.

Without bounds checks: I join a multiplayer lobby, and the next thing I know my computer is part of a botnet.

This isn't an imaginary fear, it has happened many times. Some examples from a brief search: https://gridinsoft.com/blogs/rce-vulnerability-in-gta-online... https://www.polygon.com/22898895/dark-souls-pvp-exploit-mult... https://security.gerhardt.link/RCE-in-Factorio/

I am not claiming all of these would've been prevented by bounds checking arrays, or even memory safety in general. The point is that security is not optional just because it's a game.


Now suppose your game runs in a WASM sandbox and re-run those scenarios. What do you gain from bounds checks?

I'm not suggesting that shipping without bounds checks is wise or leads to a better product. However I do think with /some games/ security is basically not a concern.


Heartbleed still happens inside a sandbox, because it's the sandboxed memory that leaks. For multiplayer games specifically, that can be a client auth key that can be used to impersonate you.


> Without bounds checks: game crashes, core dump.

I think it's more like (assuming it does actually go out of bounds at some point):

30% chance of core dump right away

20% chance of core dump at some point after errant write

40% chance it never crashes in testing

5% chance it doesn't crash the first year after shipping

5% chance it never crashes

With an explicit bounds check, all of these scenarios result in a crash at the exact location where the program first violated safety[1]. The developer gets a source-level crash and doesn't spend the first 20 minutes just trying to figure out what the crash dump even means.

[1] Hopefully with a complete stacktrace, maybe even the index and length values!

It's time we recognized that all our tooling should be designed to help us programmers who do have bugs in our program. Like, this crashing part is the normal part that all the tools should help deal with.


> What am I missing?

Sometimes without bounds checking you get an exploit instead of a crash.


The meaningless error message can be entered into Google and the user can find a thread about how to fix their specific problem instead of wading through endless threads of similar-but-unrelated problems.


the bounds check can sometimes catch the error before it corrupts your save.


And 60fps is the low end in an era where many monitors are 120-240hz. 4ms/frame is a pretty tight budget.


That's probably because of the DRM.



Interesting, thanks for the link.

I think that disabling safety checks is a thing you should only do if you are studying the cost of safety checks (i.e. a compiler switch only available to compiler engineers). IMHO, the whole point of safety checks is to find the bugs that are in your program[1]. Crashing it safely with an exact source stack trace is the nice way of both motivating you to fix it and also helping you fix it.

[1] And there are bugs in your program. Right now. Bugs. In. Your. Program. Running without safety checks is like closing your eyes and rolling the dice.


Most software should probably release with safety checks on. Certain software shouldn't (i.e. games). Toolchains like zig give you that option and respect that you can decide what's most appropriate for whatever you ship.

Arguing that safety checks should always be enabled doesn't really make sense. Context matters.


There's also the compromise of only disabling safety checks per block e.g. in your hot loop with `@setRuntimeSafety`[1] where you are confident that they aren't needed.

[1] https://ziglang.org/documentation/0.10.1/#setRuntimeSafety


I was fond of the Common Lisp loop macro that handled iterating over multiple things quite nicely:

https://lispcookbook.github.io/cl-cookbook/iteration.html#lo...

Edit: 27 years since I was paid to write Lisp....


I wrote a looping facility for guile scheme recently, which was somewhat inspired by common lisps other big looping macro Iterate: https://git.sr.ht/%7Ebjoli/goof-loop scroll down for examples.

It is more limited than the one in common lisp, since it turns all loops into tail recursion and this uses no mutation outside the higher-order looping constructs. It is written mostly using the hygienic syntax-rules facility (yes, it actually works) and I haven't managed to get it to produce slow code yet.


This is very strange, because it looks like a comprehension (https://wiki.haskell.org/List_comprehension), which would be a product iteration.

Most languages have a function called zip or something similar (https://hackage.haskell.org/package/base-4.17.0.0/docs/Prelu...) which handles pairing sequences, to be composed upstream of the iteration proper.


It's a parallel list comprehension, as linked from the wiki page you referenced: https://downloads.haskell.org/ghc/9.4.4/docs/users_guide/ext...


Pretty sure it predates most of the "similar" things mentioned. :)


Is this different from good old `zip`?


As much as a concept can be translated from one programming language to the next, they're conceptually pretty much identical. However for Zig there are two important differences:

1. It's simpler syntax than reaching for a zip function. I personally like this design because the conceptual load is pretty low as it feels like a natural extension of simple for loops. Eg you could teach someone simple for loops, then later go "hey, you could do this the whole time!"

2. Zig doesn't have support for custom iterators. Zip is doable using the existing metaprogramming features, but it's not as simple. Support for iterators also likely violates Zig's `No hidden control flow.` maxim. Plus I imagine it's a lot easier for the compiler to perform optimizations this way.

Both points combined are related to Zig's design goal for being good at writing code that can run fast on modern CPU architectures. Being able easily to loop over multiple arrays is a good step for making that practical.


While with optionals enables some iterator-like behavior. https://ziglang.org/documentation/master/#while-with-Optiona...


To expand on the parent and grandparent:

Laremere is correct in that there is no "magic" built-in understanding of iterators in the language, i.e. under the hood calling a `.next()` method, without explicitly having to call it. That _would_ violate the no hidden flow control maxim.

However, as Jayschwa points out, Zig's `while` loop will bind the result of its expression (in its own block scope) if it is non-null and otherwise exit the loop. This gives you essentially the same as a for loop that has some language-level knowledge of the iterator pattern, except there is no hidden flow control (I have to explicitly call `next`).

And indeed the Zig standard library is replete with iterators (and in most of the Zig code I write I will will write iterators for my own collections). For example, `mem.split` returns an iterator:

    var it = mem.split(...);
    
    // it.next() returns null after we run out of
    // split text and the while loop exits
    while (it.next()) |substr| {
         // In here we have a non-nil substr
    }  
> Plus I imagine it's a lot easier for the compiler to perform optimizations this way.

That's an interesting point: does Zig miss out on some optimisation possibilities with iterators given they are not a language-level construct? I don't know.


> No hidden control flow.

Off-topic, but… I very much like this idea, but I think Zig shares one wart with languages like C++: it's impossible to tell syntactically whether `f()` is a direct or indirect call. An indirect branch is a conditional branch, where the condition can be arbitrarily far away in space and time, and it's invisible. Dynamic control flow can be tricky to reason about even when you know it's there.


Interesting. To me having "hidden control flow" in your language is the key to create higher level abstractions that are easier to reason about. Same when it comes to "hidden allocation".

That is, in a good language you'd replace an explicit but verbose, error-prone and boilerplate for loop with a declaration of the output of said loop. Either through applying a map function or a python-style list comprehension.


iirc there was a proposal to have Zig used `funcptr.()` syntax (with a dot) for indirect calls, but it was rejected.


Apparently, it's zip, except with an UB if sizes don't match.


Hilarious. I was evaluating Zig, I took a look at Bun, probably the most well known Zig project. Multiple issues related to seg faults.


Take a look at TigerBeetle which is also written in Zig, if you find segfaults there they even give you money :^)

https://github.com/tigerbeetledb/tigerbeetle


It's hard to understand what tigerbeetle is about. Can anyone ELI5 it for me? As far as I can tell, it's some kind of a library/system geared at distributed transactions? But is it a blockchain, a db, a program ? (I did look at the website)


Hey thanks for the feedback! We've got concrete code samples in the README as well [0] that might be more clear?

It's a distributed database for tracking accounts and transfers of amounts of "thing"s between accounts (currency is one example of a "thing"). You might also be interested in our FAQ on why someone would want this [1].

[0] https://github.com/tigerbeetledb/tigerbeetle#quickstart

[1] https://docs.tigerbeetle.com/FAQ/#why-would-i-want-a-dedicat...


The faq helped, thanks! So, an example of typical use would be, say, as the internal ledger for a company like (transfer)wise, with lots of money moving around between accounts? But I understand it's meant to be used internally to an entity, with all nodes in your system trusted, and not as a mean to deal with transactions from one party to another, right?


Yes that's a good example!

And you can model external accounts that have their own confirmation process using our two-phase transfer support.

https://docs.tigerbeetle.com/FAQ#what-is-two-phase-commit


Great to hear! Joran from TigerBeetle here.

Yes, exactly. You can think of TigerBeetle as your internal ledger database, where perhaps in the past you might have had to DIY your own ledger with 10 KLOC around SQL.

And to add to what Phil said, you can also use TigerBeetle to track transactions with other parties, since we validate all user data in the transaction—there are only a handful of fields when it comes to double-entry and two-phase transfers between entities running different tech stacks.

The TigerBeetle account/transfer format is meant to be simple to parse, and if you can find user data that would break our state machine, then it's a bug.

Happy to answer more questions!


It's a distributed database for financial transactions, using double entry accounting, written in Zig, and with a very innovative design:

- LMAX inspired

- Static memory allocation

- Zero copy with Direct I/O

- Zero syscalls with io_uring

- Zero deserialization

- Storage fault tolerance

- Viewstamped Replication consensus protocol

- Flexible Quorums

- Deterministic simulation like FoundationDB


Zero deserialization? That sounds rather scary. This means absolute trust in data read from disk or received from other nodes?


What is the threat model you're worried about? If an attacker can write data to your disk or authenticate to your cluster, aren't you already screwed?


Yes, these are exactly my threats.

First, because I'm a strong believer in defense-in-depth. Secondly because both disk corruption and network packet corruption happen. Alarmingly often, in fact, if you're operating at large scale.


Ours too!

For example, our deterministic simulation testing does storage fault corruption up to the theoretical limit of f according to our consensus protocol.

Details in our other reply to you.


Great question! Joran from TigerBeetle here.

  "This means absolute trust in data read from disk or received from other nodes?"
TigerBeetle places zero trust in data read from the disk or network. In fact, we're a little more paranoid here than most.

For example, where most databases will have a network fault model, TigerBeetle also has a storage fault model (https://github.com/tigerbeetledb/tigerbeetle/blob/main/docs/...).

This means that we fully expect the disk to be what we call “near-Byzantine”, i.e. to cause bitrot, or to misdirect or silently ignore read/write I/O, or to simply have faulty hardware or firmware.

Where Jepsen will break most databases with network fault injection, we test TigerBeetle with high levels of storage faults on the read/write path, probably beyond what most systems, or write ahead log designs, or even consensus protocols such as RAFT (cf. “Protocol-Aware Recovery for Consensus-Based Storage” and its analysis of LogCabin), can handle.

For example, most implementations of RAFT and Paxos can fail badly if your disk loses a prepare, because then the stable storage guarantees, that the proofs for these protocols assume, is undermined. Instead, TigerBeetle runs Viewstamped Replication, along with UW-Madison's CTRL protocol (Corruption-Tolerant Replication) and we test our consensus protocol's correctness in the face of unreliable stable storage, using deterministic simulation testing (ala FoundationDB).

Finally, in terms of network fault model, we do end-to-end cryptographic checksumming, because we don't trust TCP checksums with their limited guarantees.

So this is all at the physical storage and network layers.

  "Zero deserialization? That sounds rather scary."
At the wire protocol layer, we:

  * assume a non-Byzantine fault model (that consensus nodes are not malicious),
  * run with runtime bounds-checking (and checked arithmetic!) enabled as a fail-safe, plus
  * protocol-level checks to ignore invalid data, and
  * we only work with fixed-size structs.
At the application layer, we:

  * have a simple data model (account and transfer structs),
  * validate all fields for semantic errors so that we don't process bad data,
  * for example, here's how we validate transfers between accounts: https://github.com/tigerbeetledb/tigerbeetle/blob/d2bd4a6fc240aefe046251382102b9b4f5384b05/src/state_machine.zig#L867-L952.
No matter the deserialization format you use, you always need to validate user data.

In our experience, zero-deserialization using fixed-size structs the way we do in TigerBeetle, is simpler than variable length formats, which can be more complicated (imagine a JSON codec), if not more scary.


> Where Jepsen will break most databases with network fault injection, we test TigerBeetle with high levels of storage faults on the read/write path, probably beyond what most systems, or write ahead log designs, or even consensus protocols such as RAFT (cf. “Protocol-Aware Recovery for Consensus-Based Storage” and its analysis of LogCabin), can handle.

Oh, nice one. Whenever I speak with people who work on "high reliability" code, they seldom even use fuzz-testing or chaos-testing, which is... well, unsatisfying.

Also, what do you mean by "storage fault"? Is this simulating/injecting silent data corruption or simulating/injecting an error code when writing the data to disk?

> validate all fields for semantic errors so that we don't process bad data,

Ahah, so no deserialization doesn't mean no validation. Gotcha!

> In our experience, zero-deserialization using fixed-size structs the way we do in TigerBeetle, is simpler than variable length formats, which can be more complicated (imagine a JSON codec), if not more scary.

That makes sense, thanks. And yeah, JSON has lots of warts.

Not sure what you mean by variable length. Are you speaking of JSON-style "I have no idea how much data I'll need to read before I can start parsing it" or entropy coding-style "look ma, I'm somehow encoding 17 bits on 3.68 bits"?


> Also, what do you mean by "storage fault"? Is this simulating/injecting silent data corruption or simulating/injecting an error code when writing the data to disk?

Exactly! We focus more on bitrot/misdirection in our simulation testing. We use Antithesis' simulation testing for the latter. We've also tried to design I/O syscall errors away where possible. For example, using O_DSYNC instead of fsync(), so that we can tie errors to I/Os.

> Ahah, so no deserialization doesn't mean no validation. Gotcha!

Well said—they're orthogonal.

> Not sure what you mean by variable length. Are you speaking of JSON-style "I have no idea how much data I'll need to read before I can start parsing it"

Yes, and also where this is internal to the data structure being read, e.g. both variable-length message bodies and variable-length fields.

There's also perhaps an interesting example of how variable-length message bodies can go wrong actually, that we give in the design decisions for our wire protocol, and why we have two checksums, one over the header, and another over the body (instead of one checksum over both!): https://github.com/tigerbeetledb/tigerbeetle/blob/main/docs/...


Alright, I'm officially convinced that you've thought this out!

So, how's the experience of implementing this in Zig?


Thanks! I hope so! [:raised_hands]

And we're always learning.

But Zig is the charm. TigerBeetle wouldn't be what it is without it. Comptime has been a gamechanger for us, and the shared philosophy around explicitness and memory efficiency has made everything easier. It's like working with the grain—the std lib is pleasant. I've learned so much also from the community.

My own personal experience has been that I think Andrew has made some truly stunning number of successively brilliant design decisions. I can't fault any. It's all the little things together—seeing this level of conceptual integrity in a language is such a joy.


I can't stop being amazed by TigerBeetle's design and engineering.


Thank you Nicolas!


It's a special purpose DB. No relation to blockchains.


Looking at the bugs themselves, I don't think any low level language would've caught those. C/C++ would've crashed as well (hopefully, at least, many of these problems would be UB and the compiler might just ignore the problem or patch out the offending code) and Rust would've panic'd. There are a few cases where Rust wouldn't have allowed the code to panic but the surrounding code would be pretty unreadable in safe Rust (without stacking types like Box+RefCell+Rc and clone()ing a bunch) so it's hard to compare the two.

The advantage of Rust would be a nice and readable stack trace to the crashing method, but a core dump would've included even more information for the person debugging the binary, so I think it ends up quite even.


I don’t know what’s going on in this thread where encountering UB has somehow been morphed into some kind of guaranteed immediate core dump that’s basically better than panicking anyway. Yes, people are talking about segfaults. But it’s memory corruption. Maybe you get a crash at some point, maybe you do not.

A reminder for all that have forgotten: UB is the one that can email your local council and submit a request to bulldoze the house you’re in. It is not a free core dump.


A sementation fault is well-defined behavior. If you look at Jarred's comment nearby he reveals that the pointers in question are special pointers, e.g. 0x0, 0x1, 0x2, etc.

It is 100% well-defined behavior to dereference these pointers. It always segfaults, which as Jarred mentioned is a lot like a panic.

Rust evangelists need to be careful because in their zeal they have started to cause subtle errors in the general knowledge of how computers work in young people's minds. Ironically it's a form of memory corruption.


I can buy that dereferencing null is a special case, but why is 0x2 special? Is 0x20 also special? What about 0x20000? Are the invalid non-null pointer values listed in a reference somewhere? If 0x2 is an invalid pointer, what do I do if my microcontroller has a hardware register at 0x2?


On many platforms, the zero page is set up so access to it will always segfault. This isn't a language guarantee, but it's a guarantee in most modern operating systems (Linux, FreeBSD, Windows). This is set up for pointers all the way up to the end of the first page.

On Windows and Linux this is the first 4KiB so range 0x0000 up to 0x1000, unless large pages are on (then it's even more).

On macOS in x64 this is the entire 4GiB memory space, probably a method to help developers port their 32-bit software to x64. I don't know what the zero page size on ARM is.

If your microcontroller doesn't have this guarantee, you can't make use of this feature.


That's a guarantee on the level of the hardware/OS, but hardware semantics are not the same as language/compiler semantics. Even if according to the source code you're dereferencing a pointer value 0x0 or 0x2, that doesn't mean the compiler-emitted machine code will end up telling the hardware to do the same.

Remember this gem?

https://kristerw.blogspot.com/2017/09/why-undefined-behavior...

Once you trigger UB, all bets are off and your code could do anything. A segfault just means you spun the roulette wheel, bet it all on red, and got lucky your house wasn't bulldozed.

Zig also uses LLVM under the hood, right? So it's subject to these same semantics. An LLVM pointer value cannot legally contain arbitrary non-null non-pointer integers such as 0x2. That's a dead giveaway of UB. And I doubt the emitted Zig code safety-checks every pointer dereference for a value less than 0x1000 before performing the dereference.


The semantics are actually operating system and even compiler flag dependent. On macOS you can choose the size of your zero page during build. The numbers I've listed are just the defaults.

Zig UB is not C UB. There is an entire language built on top of it. Just because something behaves a certain way in C, doesn't mean the same thing is true in Zig. Zig is no longer a code generator for C, it has switched to a self hosted compiler a while back. In fact, the language is rapidly progressing to the point where LLVM is a mere optional dependency.

I don't know the semantics around LLVM pointers. I don't see why 0x2 would be invalid, there are plenty of platforms programmed in C(++) that have a flat memory model. It would be quite painful to have a microcontroller where you can't send data to the output pin because LLVM decided that 2 is invalid (but 0 isn't). I've never seen LLVM complain about invalid dereferencing, though, it always ends up doing what the compiler tells it to do as far as I can tell.

Zig pointers will definitely cause UB but most Zig code shouldn't need them. Slices are actually bound checked and should probably be preferred in most cases of pointer arithmetic. Simple pointers can't be increased or decremented so you need to manually go through @intToPtr if you want to do real pointer arithmetic, which is quite unusable.

I haven't used Zig much so I don't know how many Zig semantics are copies of C semantics and how many are translated by the Zig frontend. However, "this is a bad/undefined thing in C so it must be a bad/undefined thing in Zig" is simply not true.


I know Zig is not C, that's why I specifically mentioned LLVM. It's fine if Zig has different opinions about UB than LLVM does, but in that case ReleaseSafe builds should not use LLVM, not even optionally. If Zig says some operation is defined, but LLVM says it's undefined, well, LLVM is the one optimizing code so it's LLVM's invariants that matter. Right now it looks like Zig is playing fast and loose with correctness, shoving everything through LLVM but not respecting LLVM's invariants. And hey, if something is observed to segfault under some conditions today on the current version of LLVM, we'll just say segfaults are guaranteed. It's disappointing to see.


A lot of people have the same misunderstanding as you.

LLVM has rules about what is legal and what is not legal. If you follow the rules, you get well-defined behavior. It's the same thing in C. You could compile a safe language to C, and as long as you follow the rules of avoiding UB in C, everything is groovy.

Likewise, this is how Zig and other languages such as Rust use LLVM. They play by the rules, and get rewarded by well-defined behavior.


Is not one of the LLVM rules, pointers must be valid and have a valid provenance in order to be dereferenced? If 0x2 ends up in a pointer that is dereferenced (or 0x0 in a nonnull pointer), has that rule not been broken? And if the rule is broken, does that not trigger undefined behavior?


I invite you to share a snippet from the LLVM language reference[1] that backs up your interpretation.

I will return the courtesy, with regards to my interpretation:

> An integer constant other than zero or a pointer value returned from a function not defined within LLVM may be associated with address ranges allocated through mechanisms other than those provided by LLVM. Such ranges shall not overlap with any ranges of addresses allocated by mechanisms provided by LLVM. [2]

[1]: https://llvm.org/docs/LangRef.html

[2]: https://llvm.org/docs/LangRef.html#pointer-aliasing-rules


From the same section,

- Any memory access must be done through a pointer value associated with an address range of the memory access, otherwise the behavior is undefined.

- A null pointer in the default address-space is associated with no address.

A null pointer (0x0) is associated with no address, therefore it has no address range. So if you do attempt a memory access (dereference), the behavior is undefined. QED. A naive translation to assembly would indeed segfault on a modern OS, but LLVM's optimizations are free to assume that code path is unreachable and do anything else.

Once the program is in this state, a bug of some kind is unavoidable. I don't take issue with that - what I take issue with is your claim that this behavior is well-defined, because it definitely is not. It would be equally valid for a null dereference to corrupt your program state or wipe your hard disk.


You have already admitted that 0x1, 0x2, etc. are fine. Your remaining argument rests entirely on the incorrect premise that Zig's only option is to lower to LLVM IR using the default address space.


I don't think 0x2 is a valid pointer either. The docs say the pointer value must be "associated with address ranges allocated through mechanisms..." - to me the word "allocated" means it's the result of an allocation, pointing at usable address space. (Sorry, I know this is a purely semantic argument. Debating the meaning of words does not make for very interesting discussion.)

In Rust for example, derefencing a raw pointer is unsafe - because that pointer could have a value of 0x2 - which would result in undefined behavior according to LLVM.

tbh I'm surprised any of this is even up for debate. If you google "is segfault undefined behavior" you'll get 100 results telling you yes, yes it is.


Are you claiming that any program that segfaults exhibits undefined behavior within LLVM semantics, even those that were not compiled by LLVM? Or within some other set of semantics shared by all programs that can segfault?


I'm claiming that if a program is compiled with LLVM, it must follow must LLVM's rules. One of those rules is that a pointer must be valid in order to be dereferenced. If a program attempts to dereference an invalid pointer and segfaults, it has broken those rules* and thus exhibited undefined behavior. While undefined behavior MAY result in a segfault, it's equally valid for the program to continue running with corrupted state and wipe your hard disk in a background thread.

I'm not sure how I can connect the dots any more clearly. Like gggggp said, it's baffling to see the creator of a popular language sweep the nasal demons under the rug and pretend that certain undefined behavior is guaranteed.

Calling such segfaults "safe" or "well-defined" is setting your users up for disappointment and CVEs, because a "well-defined" result is axiomatically impossible in the presence of undefined behavior. It's subtle, and if we were talking about a Java competitor maybe I could forgive the mistake. But if you're writing a low-level language it's important to understand how this stuff works. Ironically, he spread misinformation in the very post where he accused Rust evangelists of the same.

This thread is long dead and continuing the discussion seems futile, so I'll just leave it at that.

*excluding something silly like `raise(SIGSEGV)`


Sure, I think I understand. The claim is maybe that it's legal for LLVM to emit code that (before every pointer access to a pointer obtained from outside of LLVM) somehow checks whether the pointer points to a region of memory that was actually allocated outside of LLVM and does different stuff based on the result of that check. In the face of such adversarial codegen on the part of LLVM, if someone wanted to implement this correctly on Linux, they might need to make sure they actually mapped the pages they wanted to use for crashing with PROT_NONE before using any pointers pointing into the crashing region. Is that right?

Do the docs actually define exactly which mechanisms external to LLVM count as allocating address ranges and which do not? It's possible that calling mmap and passing PROT_NONE does not count, for example.


I wouldn't call codegen adversarial. The optimizer isn't out to get you. It emits the best code it can given a certain set of assumptions. It may just seem adversarial at times because the output can behave in unintuitive ways if you break those assumptions.

I don't believe PROT_NONE suffices. The address needs to be accessible, not merely mapped. If reading through a pointer, the address must be readable. If writing through a pointer, the address must be writeable. This is why writing to a string constant is undefined behavior, even though reading would be fine.

Another issue is alignment. If you read from a `*const i32` with unaligned pointer value 0x2, the optimizer is free to assume that code path is unreachable and, you guessed it, bulldoze your house. If you get a segfault from reading an `i32` from address 0x2, you've already hit UB and spun the roulette wheel.

In theory the emitted code could check pointers for alignment and validity (in whatever platform-specific way) before accessing them, and simulate a segfault if not. Such checks would serve as optimization barriers in LLVM, and prevent these instances of UB. Of course Zig's current ReleaseSafe doesn't do this, and I think it would be silly if it did. But that's the only way you could accurately call segfaults "well-defined".


> An LLVM pointer value cannot legally contain arbitrary non-null non-pointer integers such as 0x2.

0x2 is a perfectly valid pointer value, it just happens to never be a good virtual memory address on modern systems where virtual memory is setup by the usual OSs, hence the fact that you can rely on it segfaulting.


> On many platforms, the zero page is set up so access to it will always segfault. This isn't a language guarantee, but it's a guarantee in most modern operating systems (Linux, FreeBSD, Windows). This is set up for pointers all the way up to the end of the first page.

Then I guess it could be a language guarantee if Zig only supports/targets those platforms. However, considering how low-level Zig is, I doubt that that is the case.


> I can buy that dereferencing null is a special case, but why is 0x2 special?

It isn't, in the general case. But JavaScript engines do some dark magic with pointer packing / NaN boxing as a performance optimization (most things in the VM are single words, passing around a single word is usually way cheaper than full unboxing), and I suspect bun in occasionally running into issues where it gets returned something from the JS engine which it thinks is a pointer but actually it's a packed, special value. This is a logic error, that turns into a weird memory issue at the abi boundary, not a memory safety issue.


Not even 0x0 in the general case it could a legit address.


> If you look at Jarred's comment nearby he reveals that the pointers in question are special pointers, e.g. 0x0, 0x1, 0x2, etc.

Is that guaranteed by the language semantics, or could it possibly change at some point in the future? If it's the latter, then yes, it is very much Undefined Behavior, and not guaranteed to segfault before opening the door for potential exploits.


> It is 100% well-defined behavior to dereference these pointers. It always segfaults

Not on every architecture, not in LLVM (even if well-defined on the underlying architecture), and not in C (even if well-defined in the underlying compiler backend).


You appear to have particularly vengeful nasal demons.


First of all: Zig is not C. The rules for undefined behaviour can be found here: https://ziglang.org/documentation/master/#Undefined-Behavior

TL;DR: Zig injects checks and aborts the program at runtime unless you specify that you wish to ignore the problem. This can be done explicitly within the code or by compiling under a build mode that ignores checks (unless specified manually).

Programs compiled as Debug and ReleaseSafe will terminate at runtime if UB is triggered. Compiling for ReleaseSmall and ReleaseFast will cause traditional C-style UB. If you care about your program doing what it's supposed to do, you use ReleaseSafe. Doing Release[Fast|Small] will do something similar to -O3 in other languages, which will often change behaviour.

Note, however, that you can compile your code under "just allow UB and see what happens" mode but still benefit from checked UB by setting @setRuntimeSafety(true); this will introduce the assertions despite the unsafe build modes you may specify.

It's like introducing a C++ compiler flag* telling the compiler "ignore exceptions and just continue". You know you're in for a bad time the moment you specify it, but it makes your program blazingly fast because it greatly reduces the amount of code to generate/checks to execute.

The main advantage of checked UB is that well-tested code can make use of the unchecked nature of these features for speed without having length check code blocks that need to be wrapped in debug #ifdefs or similar. Assuming you don't run test builds with checks enabled (and why wouldn't you) you'd catch these problems in your build pipeline.

This is different from the normal way of working with C and friends, where UB remains in debug/-O1 builds but just acts a little differently. Some compilers will insert breakpoints, others will ignore the problem like in release mode, nobody knows what will happen and your compiler can't detect this problem for you.

* note that -fno-exceptions exists, but that aborts the program rather than let it continue.


A panic (deterministic, guaranteed, immediate, and worst-case a dos) is an order of magnitude better than memory corruption (non-deterministic, not guaranteed, eventual-if-at-all, and worst-case-rce).


Most of what manifests as a segfault in Bun have been due to assuming a JSValue is a heap allocated value when it is (the JavaScript representation) “null”, “undefined”, “true”, “false” etc. These are invalid pointers, the operating system signals the memory access was invalid, Bun runs the signal handler, prints some metadata, and exits. This is a lot like a panic


Well, Zig would not be such a good C replacement if it did not also allow segfaulting all over the place.


Those are safe depending on what kind they are.


safety-checked UB; an important distinction. I assume.


Not very. It really just means that "there is a sanitized mode for building", which already exists in many C and C++ compilers (for language UB) and standard libraries (for library UB).


I think Zig's ReleaseSafe mode is intended to be suitable for production, which IIUC isn't really the case with ASan, UBSan, and friends. Those have some performance problems and also some attack surface problems.


OK, but is "safe" in ReleaseSafe any kind of guarantee, or is it just safer than ReleaseFast?

I can enable lightweight assertions in libstdc++ and libc++ and it makes C++ safer, but not in any way "safe". There are some flags that can be enabled to trap on some language UB too, without bringing in the heavy weight sanitizers.


Last time I checked (more than a year ago) there were major open questions about what could be guaranteed. My impression was that you could expect e.g. all array accesses to be bounds checked, but that use-after-free and dangling pointers were still issues, especially if you use the C allocator.


I think of this case as an assert() that both lists are the same length. if they're not, I want to know about that via a crash.


Could you elaborate? What's a safety-checked UB?



Thanks.

So, if I read this correctly, barring the simple cases that Zig can detect at compile-time, this means that whether it's a UB (in the C++ definition of the term) depends on the flags specified by the author of the library and the person who compiles the final binary.

That's definitely much better than C++ UB. Still a bit scary, though.


Is it UB?

I thought it was defined as an OOB access in non-safe modes, or a panic in safe-modes.


It's zip with an arbitrary amount of array parameters. Quite useful for the purposes provided by the article.

I do wonder about performance, though, as multiple array derefences may not be captured well by the L1 cache like a well-rounded struct might.

An L1 cache line is often 64 bytes long, enough to fit one of the "monster" example structs but never two. Performance in real life scenarios may actually increase if these structs are padded with an additional 16 bytes so none of the structs are on a cache line boundary.


> It's zip with an arbitrary amount of array parameters.

So… zip?

Python’s does that, and for most other langages you could use overloading, basic macros, or traits trickery to get there if you really wanted to support unreasonable widths (IME you almost never need more than 3, and combining two zip/2 works fine then).


There is no single "zip". Java's .zip() will work on two sources, as will C#'s Zip(). Haskell's zip is no different, only accepting two parameters. I don't know any language other than Python that shares Python's iterator zip() implementation.

In implementation, Python's zip will return a generator that is iterated over using the iterator functionality, while Zig's .zip is compiled as a loop. Python's iteration may be turned into a loop, it may be interpreted, or it may be turned into some other kind of bytecode, who knows. The standard cpython implementation is much more complex, though: https://github.com/python/cpython/blob/main/Python/bltinmodu...

Concatenating zip()s is an unnecessarily complex solution, both in terms of syntax and in code generated. In Python this may not matter because it's a relatively slow programming language in general (the language often being "glue between fast C methods"), but in Zig this can easily become untennable.

I also disagree that you don't need more than 3. As the article states, if you leverage array-of-structs rather than struct-of-arrays you can use this to "deconstruct" objects without paying the memory usage penalty of struct padding. The 15% wasted RAM in this example is relatively small compared to some real use scenarios; something as common as a 3D vector will often have a whopping 25% space waste.

Other languages allow this as well (and often using such iterations are much faster than zip()ing lists together) but the lack of guarantees and repetitive syntax becomes a pain.


> There is no single "zip".

Which means you can implement yours to fit your needs.

> I don't know any language other than Python that shares Python's iterator zip() implementation.

https://docs.rs/itertools/latest/itertools/macro.izip.html

> In implementation

Which is hardly relevant. Python's entire implementation has aims, means, and purpose with no relation to Zig's.

> I also disagree that you don't need more than 3.

Which is not what I wrote.

> As the article states, if you leverage array-of-structs rather than struct-of-arrays you can use this to "deconstruct" objects without paying the memory usage penalty of struct padding.

Sure? And the article uses an example with 3 values.

> The 15% wasted RAM in this example is relatively small compared to some real use scenarios; something as common as a 3D vector will often have a whopping 25% space waste.

It also could hardly be less relevant: it's an issue in an AoS structure because all your objects have that overhead, therefore that's your total overhead.

Here it's 15 or 25% padding in a single value within a stackframe. You're probably wasting more stackframe space due to the compiler not bothering reusing temporally dead locations.

And that's if the compiler reifies the tuple instead of eliding the entire thing.

> Other languages allow this as well

OK?

> (and often using such iterations are much faster than zip()ing lists together)

Until they are not.


> Which means you can implement yours to fit your needs.

Which this doesn't, as zip is an expression and multi-sequence loops aren't.

> https://docs.rs/itertools/latest/itertools/macro.izip.html

External libraries aren't part of a language.

> Which is not what I wrote.

I admit, I read over the "almost" in "you almost never need more than 3".

> It also could hardly be less relevant: it's an issue in an AoS structure because all your objects have that overhead, therefore that's your total overhead.

> Here it's 15 or 25% padding in a single value within a stackframe. You're probably wasting more stackframe space due to the compiler not bothering reusing temporally dead locations.

That's not true: arrays are byte-addressable so inside an array the alignment can be shorter. An array of 121 33-byte values is 3993 bytes in size, an array of 121 usizes is 968 bytes in size, and assuming enums resolve to 32-bit values an array of 121 enums is also 484 bytes in size. There is no overhead here.

This has advantages and disadvantages. Unaligned access is slower in general but in many cases and unaligned array can be faster because of how many of its entries can be loaded into the CPU cache. There's no definite advantage here in terms of CPU performance, but in terms of RAM usage there is.

> Until they are not.

When does a for loop ever become faster than a generator? The values being mapped over are already evaluated, there is no lazy loading+early stopping to take advantage of the generator.


Zig's "zip" is purely syntactical, where Python's zip is a generator. This is significant both in terms of performance (Zig wins) and flexibility (Python wins).

Unlike Python, you can't pass a zip generator around. It's just a for-loop. While zig loops are expressions, they only return a single value.


> This is significant both in terms of performance (Zig wins)

Does it, actually? Does Zig's built-in pseudo-zip outperform Rust's? Or C++23's?

> It's just a for-loop.

Except it's not "just" a for loop, it's a weird special case for a for loop. And one which is actively dangerous too.


> Except it's not "just" a for loop, it's a weird special case for a for loop.

You could also argue that a for loop which can only iterate over a _single_ sequence is a special case of a multi-sequence for loop.

> And one which is actively dangerous too.

In safe builds (ReleaseSafe, Debug) it will cause a controlled panic if the sequences are not of the same size. Most likely it's a logical bug if you iterate over two sequences of different sizes. In ReleaseFast the compiler will make assumptions to improve performance. If it's very important for your code you can force a certain code block to always have runtime safety. Yes, there are trade-offs, but I don't feel it's _unreasonable_.


> You could also argue that a for loop which can only iterate over a _single_ sequence is a special case of a multi-sequence for loop.

When a "for loop" in virtually every language is not multi-sequence, not really. People expect a "for loop" to be a certain kind of thing.


Please check the context, I was comparing to Python. And please chill out. It's only "dangerous" when you decide to run it that way.


[flagged]


Hi, no need to be so abrasive about it.

> Please respond to the strongest plausible interpretation of what someone says, not a weaker one that's easier to criticize. Assume good faith.

Python's zip function returns a generator. A generator in zig would look like a function pointer with a closure. If zig were to implement the Python-style "zip" function, constructing the closure, and iterating over the generator, would be significantly slower than the naked "just a for loop" that we see in TFA. And that's not even considering the tuple construction (oops, now we need an allocator) & unpacking.

Ergo, the zig-style "syntactical zip" is higher performance than the Python-style "functional zip". Even when you cut through the baseline performance differences between the languages.


> Hi, no need to be so abrasive about it.

That's just projection.

> Please respond to the strongest plausible interpretation of what someone says, not a weaker one that's easier to criticize. Assume good faith.

I'll get right on that as soon as you extend the same courtesy, which you have refused to do at every opportunity.


Nit: It should be “Air Nomads” instead of “Wind Nomads”.

(I know this doesn’t matter but I figured the author would appreciate the heads up!)


thanks, fixed, I started by thinking about the last example (the one about pokemons) and then it stuck


I really like that for loops can be expressions. It seems obvious in hindsight, but hindsight is always 20/20 :)


> It seems obvious in hindsight

It's not, because most languages don't have an `else` clause in their for loop (and in my experience with Python that clause is quite confusing so its use is not common).

And a for loop can be executed 0 times, so without a mechanism for a fallback it might not have a value to yield.


I think a language where it is an expression should also let the loop accumulate a value. In those cases you could just have it return the identity/base type of whatever you are accumulating.

Say like the for loops in racket, or my own loops for guile scheme: https://git.sr.ht/%7Ebjoli/goof-loop


> And a for loop can be executed 0 times, so without a mechanism for a fallback it might not have a value to yield.

I would think that and the similar case where no iteration hits break are solvable by having a for loop return an optional type.


Is must say this looks pleasant, coming from Kotlin. Also ranges seem to work very similarly.

Not sure how I fell about the UB - is it really necessary to optimise away a single length check per loop (not iteration)?


That's up to the programmer. Zig's default build mode is Debug, and ReleaseSafe is recommended if you don't require extreme performance. Both modes will insert the check.

Safety checks can also be enabled or disabled on a scope-by-scope basis if desired.


Special-casing (same-length) zip and iteration+count might make sense for an imperative language which doesn’t want to go down the rabbit hole of implementing efficient, lazy iterators. It doesn’t make sense in a language where you want the flexibility of switching between (as in: compiling to) serial loops and paralell code, but it makes sense for a language which leans more towards what-you-see-is-what-you-get rather than sufficiently-smart-compiler.


Tbh, there are limits to how much any language that does "wysiwyg" compilation that would have for loops. For example, any "for" loop can be a "while" loop in asm, the one optimization is you can use the index registers as long as the number of arrays is less than the number of index registers you have. If it is more, which the language does not constrain of course, you just go back to a loop with memory locations for pointers. But of course, in that case then, you must have a "smart compiler" that can decide that which case it is and thus compile to the right code.

That said, this likely will be an esoteric case on most modern machines (like x86_64 has 16 regs that can be used for indexes) and I doubt people want to use this for like avr.


Here is Awk with C99 preprocessing: cppawk!

Loop macro for parallel/nested iteration featuring a (user-extensible!) vocabulary of clauses:

  $ cppawk '
  #include <cons.h>
  #include <iter.h>

  BEGIN {
     loop (list(iter0, item, list("alpha", "charlie", "bravo")),
           list(iter1, ltr, list("a", "b", "c")),
           range(i, 1, 3))
     {
       print item, ltr, i
     }
  }'
  alpha a 1
  charlie b 2
  bravo c 3
This is a tiny shell script plus a collection of header files in a small directory structure. It requires an Awk such as GNU Awk, and the GNU C preprocessor.

Preprocessed programs cam be captured, to run on systems that don't have the cppawk script or a preprocessor, and with less startup overhead.

https://www.kylheku.com/cgit/cppawk/about/


> The new multi-sequence syntax allows you to loop over two or more arrays or slices at the same time

In Haskell, this is called a parallel list comprehension:

  [x+y | x <- xs | y <- ys]
In a normal list comprehension, you have a single pipe, in a parallel one you have as many pipes as how many lists you are zipping. https://downloads.haskell.org/ghc/latest/docs/users_guide/ex...


No, your list comprehension is a product (it iterates ys for every x). The feature here is zip.


I'll quote from the documentation link I referenced:

> For example, the following zips together two lists:

  [ (x, y) | x <- xs | y <- ys ]
That's precisely the difference between a normal list comprehension (one pipe) and a parallel list comprehension (multiple pipes).

For clarity, here's your normal list comprehension (with one pipe) that produces all the combinations instead:

  [ (x, y) | x <- xs, y <- ys ]
And here's the full example from the article converted to Haskell and producing the exact same output:

  {-# LANGUAGE ParallelListComp #-}

  import Control.Monad (mapM)

  elems = [ "water", "earth", "fire", "air" ]
  nats = [ "tribes", "kingdom", "nation", "nomads" ]

  main = mapM putStrLn
      [ show idx ++ " - " ++ e ++ " " ++ n
          | e <- elems
          | n <- nats
          | idx <- [0..]
      ]
EDIT: I suppose an explicit zip with an anonymous function looks more idiomatic though:

  main = forM (zip3 elems nats [0..]) $ \(e, n, idx) ->
      putStrLn (show idx ++ " - " ++ e ++ " " ++ n)
EDIT2: Best of both worlds with the list monad?

  main = mapM putStrLn $ do
      (e, n, idx) <- zip3 elems nats [0..]
      [ show idx ++ " - " ++ e ++ " " ++ n ]


Question for Zig experts:

    for (elems) |x| {
       std.debug.print("{} ", .{x});
    }
Why is the .{x} necessary here? What happens if I just write "x"?


Zig doesn't have variable length args. The .{} syntax is for an anonymous struct with no field names (which are named tuples in Zig.) Print takes the struct's type info at compile time to check the validity of the statement, and also produce optimal code. This is implemented entirely within Zig's standard functionality that's available to all users.

So, if you just type X, you're getting an error about it not being a struct. That's unless X is a struct with one field, where it'll just print that field. I find Zig meta-programming to actually be fairly readable, here's the function that does the formatting: https://github.com/ziglang/zig/blob/master/lib/std/fmt.zig


`std.debug.print` and similar fmt-like functions take 2 arguments. The first argument is a format string, and the second argument is a tuple. I think a tuple is an anonymous struct whose members are named 0, 1, 2, etc., but I'm not completely sure on this. If you just write "x", it won't work, since you needed to pass a tuple containing 1 thing, and x probably isn't a tuple containing 1 thing.


Zig doesn't have varargs anymore. Instead, it has anonymous structs/arrays/tuples. The second argument to `print` here is expected to be a list of the values referenced by the `{}` placeholders in the string in the first argument.

`.{a, b, c}` is the syntax for an anonymous struct/array/tuple, and a single element still needs to be wrapped in it.


The print function is implemented in the std library[1] not the compiler and Zig does not have varargs

[1] https://github.com/ziglang/zig/blob/f6c934677315665c140151b8...


I think the naming of the ‘else’ branch in the loop could be more telling, like using the name ‘finally’ or ‘finish’.


I agree, but ‘finally’ or ‘finish’ IMO aren’t good choice because that code doesn’t always execute.

I think I would go for something expressing ‘default’, but would first look at existing code to see how common this is, and if I decided I wanted this feature, look hard for alternative syntax.

  const match: ?usize = for (text, 0..) |x, idx| {
     if (x == needle) break idx;
  }
could return an optional int, for example. If so, you would get a ‘null’ for free, and if you didn’t want a null, you could tack on a .getOrElse(NOT_FOUND).

I guess they picked this because Python has it, too. https://docs.python.org/3/tutorial/controlflow.html#break-an...:

“Loop statements may have an else clause; it is executed when the loop terminates through exhaustion of the iterable (with for) or when the condition becomes false (with while), but not when the loop is terminated by a break statement.”


`finally` hints at a very different behaviour because in most languages' context a finally clause is executed whether an exception is raised or not.


Kinda nice to have, D can do it aswell:

    import std;

    void main()
    {
        int[] a = [1, 2, 3];
        string[] b = ["a", "b", "c"];

        foreach (e1, e2; zip(a, b))
        {
            writeln(e1, ":", e2);
        }
    }

    1:a
    2:b
    3:c


``` for (monster_elem_types, monster_hps) |et, hp| { if (et == .fire) hp. +|= 1; // saturating addition } ```

Anyone knows what language Zig was inspired by(if any)? This doesn't look intuitive nor does it resemble any common syntax.


|x| for captures is from Ruby.

Special ops are formed by adding another symbol onto an operator: wrapping ops are +% -% etc, saturating ops are +| -| etc. This pattern was perhaps inspired by OCaml, which uses +. -. etc for floating point ops. The | for saturating is because "| looks like a wall".

The <op>= syntax is of course from C.

p.* is pointer deference. It was probably chosen for parsing reasons. The * is C for dereference, the . prevents ambiguity with multiplication. It may have been inspired by the postfix p^ from Pascal.

The .fire is just MyEnumType.fire with MyEnumType omitted; the type will be inferred.


This is a gripe I have about Go -- a very minor gripe, to be sure, but it's still there. If you want to iterate over two arrays/slices that have the same length, you have to choose between:

    for i := 0; i < n; i++ {
        fn(foo[i], bar[i])
    }

    for i := range foo {
        fn(foo[i], bar[i])
    }

    for i := range bar {
        fn(foo[i], bar[i])
    }

    for i, x := range foo {
        fn(x, bar[i])
    }

    for i, y := range bar {
        fn(foo[i], y)
    }
But none of these are satisfactory; what I really want to write is:

    for _, (x, y) := range (foo, bar) {
        fn(x, y)
    }


This is pretty ugly and add the overhead of a function callback, but just for fun:

    func multiLoop[X, Y any](x []X, y []Y, cb func(i int, x X, y Y)) {
        if len(x) != len(y) {
            panic("invalid slice lengths")
        }
        for i := 0; i < len(x); i++ {
            cb(i, x[i], y[i])
        }
    }

    func foo() {
        multiLoop([]int{1, 2, 3}, []string{"a", "b", "c"}, func(i int, x int, y string) {
            fmt.Println(i, x, y)
        })
    }


FWIW this is often called `zipWith`, or sometimes just `map` (some `map` implementations can take a variable number of sequences to map over).


coming from total unawareness of zig: in the for (1..5) construct, these integer ranges consistently not including the upper limit element when lists do include the last element, seems surprising. i guess it's a range boundary (1 TO 5), not a list (1 THROUGH 5), but the other behavior feels like a list, so it feels like 5 should be in.


I completely agree with you on the madness of not including the upper limit. However, I don't see how the phrase "one to five" would not include five. "Rate this film on a scale of one to five" does not mean four is the highest rating.

It translates to "increment from one, stopping before you get to five". Ridiculous.


It's not ridiculous, "1 to 5" translates into it starts with 1 and ends with 5, and both versions are ambiguous on the point of including the endpoint or not. In a programming context, it seems "clear" that it's ambiguous or down to convention.


It's consistent with (some) other languages, for example iirc in ruby .. is exclusive of the last item and ... Includes the last item.


Don't think of it as TO, think of it as increasing UNTIL.

1 increasing UNTIL 5.

It is genuinely recommended (Djikstra onwards) to specify ranges as Start Value (inclusive) Until Final Value (exclusive).


>In the multi-sequence for loop version it’s only necessary to test once at the beginning of the loop that the two arrays have equal size, instead of having 2 assertions run every loop iteration.

So, I'm assuming zig generally cannot be used with multi-threaded code? Can the underlying arrays not be modified during the whole loop execution?


That's broken in every language, except for the few which just don't allow doing it. So I'm not quite sure what the question is about.


Arrays can be modified, but their size is a part of their type, just like C.

For slices, length is only known at runtime, but it's immutable once the slice is created, so there's no issue there, either.


Off topic, but I was weighing up trying Zig last night for a project.

No doubt Zig has changed alot and is better than it was only a year or two ago.

Is anyone here willing to say if they have experienced success and satisfaction using Zig? I'm wanting to do some C library interfacing.


I’ve absolutely had satisfaction with my several personal projects written in Zig. And based on an imperfect measurement (GitHub stars) I have also had moderate success in making something useful. It’s a terminal fuzzy finder [0]. I also maintain a Zig Lua bindings package [1], and I’m working on a port of an old Macintosh game [2].

Zig is exactly what I want out of a language though, so take my opinion with a grain of salt :)

[0]: https://github.com/natecraddock/zf

[1]: https://github.com/natecraddock/ziglua

[2]: https://github.com/natecraddock/open-reckless-drivin


>> Zig is exactly what I want out of a language though

What is exactly what you want from a language?


I value simplicity and control.

Zig is a very consistent language in syntax and semantics, so there are a small number of features I need to be concerned with. My understanding is that once Zig reaches a stable 1.0 the language will not change. Although there is a lot of churn right now, I appreciate the idea of a language that is simple, and stays simple.

The code is also very readable. I haven't found another language (yet) that I can just open up the standard library source code and understand just about everything. With no hidden control flow I can easily read a function and not have to question what a line of code does. Everything it does is right in front of me.

I also love that Zig is trying to fix many of C's problems. Rather than a global libc allocator, each function that can allocate (by convention) accepts an allocator as an argument. In my projects this has been really great for understanding what code paths allocate, which has made it easy to package my fuzzy finder as an allocation-free Zig module and C library.

Now, if I were working on a project with more critical safety requirements, I might consider a different language. But for most of my personal projects Zig is exactly what I need.


Hey Im just a student and cant even think to build stuff of complexity most of the guys here make rn, but I made a json parser in zig and it was fun.


I have absolutely no problem with the good old C-style for loop syntax.

I think that a separate foreach or for-each loop, made for built-in or extended containers could be a nice addition.

Not seeing much value there.


This Data Oriented Design thing they mention later is basically just giving a name to an anti-pattern that is obvious to everyone since 1987.


I'm not sure zip is used enough to add it to the language but since they are also using it for tracking the index maybe that is the primary use case.


The example of using SoA memory layout is not there just as a random example. We hope for Zig developers to employ DOD principles whenever appropriate, which is not going to be that rare in a low-level programming language like Zig.

Andrew has a full talk about how the Zig compiler benefits tremendously from DOD:

https://vimeo.com/649009599?embedded=true&source=video_title...


Honestly working with them in a column oriented way makes a lot of sense. I wonder though if that should just be handled at the struct level? i.e. ask for row vs column layout.


How would it work with userspace memory allocators?


A quick syntax question:

   std.debug.print("{} ", .{n});
What is the '.' before '{n}' for?

Thanks!


Hang on, I was reading last night that Zig has no for loop? That you have to use while.... is this not correct?


It has no “for (init; cmp; step)” type loop, and instead you had to use:

   var i: usize = 0;
   while (i < sz) : (i += 1) {
       ...
   }
Meaning that the scope for i would leak.

It did have a foreach-style for loop, as seen in the article though.


Looks like they have the most important part in place, the increment before the next iteration.


Probably a good signal for potential O(n^2) when reading the code.

EDIT: Nope. I was wrong. This is not a list comprehension.


Why do you say so? It's equivalent to iterating indexes from 0 to N-1 of two (or more) lists with N elements and providing syntax sugar for those lists' elements at that index. This is O(N).


True. I misread it, it's a zip pattern. I thought it was a fancy list comprehension.


Right, it's not an ordinary list comprehension. It's a parallel list comprehension though:

  zip as bs = [(a,b) | a <- as | b <- bs]
https://downloads.haskell.org/ghc/latest/docs/users_guide/ex...


Zipwith is a great iteration api to have available.


Sorry, but using new syntax to accomplish something other languages have as library code is not clever.

When reading zig code you have to stop and think, "wait does this syntax mean zip or direct product?" But when expressed as a function called zip, the meaning is clear.

(Obligatory reminder that zig devs think that sometimes running code inside `if (false)` is a minor bug of no consequence, and after all what are the real motives of anyone pointing it out, eh?)




Consider applying for YC's Fall 2025 batch! Applications are open till Aug 4

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: