Zig Language Features

Posted this on Reddit and was referred to this site instead. Putting this here for any thoughts from the community / any explanations as to why my ideas are crap haha

Does anyone know if Zig has any more planned language semantics to be introduced pre 1.0?

A couple things I’d personally love to see:

#1
make()
free()
destroy()
into the global namespace ala Odin / Golang

#2
std.mem.Allocator
std.Io
const std = @import(“std”) avail by default

Same as above but less passionate about these

#3:
.tag = val instead of .{ .tag = val } syntax for tagged unions

#4 Default function params

  • potentially for implicit allocator use ala Odin

I understand that a lot of the goal of Zig is to make things as explicit as possible, I find that there is a lot of unnecessary typing in the syntax and in function signatures that makes the language a little more verbose than I’d like. Curious if any of these are either planned / maybes / or never gonna happen.

Welcome to ziggit @arod1213

I believe that as you use zig more, you will understand why language suggestions are not accepted for now, but everyone suggests their own favorite features anyway.

There is a good alternative for #1:

const foo: Foo = .make();
defer foo.destroy();
1 Like
  1. This is a bad idea for libraries, as it removes choice and control from the caller, preventing optimal software. For your own non-library projects, if working off of a global allocator improves your code then it’s perfectly fine to do so.
  2. A few lines at the top of your file to write this out is fine.
  3. Man, that’d be nice. However the ideas falls down when you try to work through the implications on parsing.
  4. Mostly this is a bad idea that leads to bad code. Sometimes it’s a good idea instead to take a single argument which is a struct that has default values. See, eg std.Build.Module.CreateOptions

Mostly, it sounds like you just need to be fine writing out a little bit more. Code is read far more than it is written, so that use case is optimized for. Being explicit helps reading and understanding code. Optimizing for keystrokes is one of those ideas it’s better to just let go of, things get a lot more pleasant once you do.

6 Likes

Of course, was more curious about if there any things that are planned as it gets closer to 1.0.

Are there explicit reasons why std is not avail in the global namespace?

In your .make() example, if I’m not mistaken that is only valid if Foo has a method make. I was thinking more along the lines of a method like this

var ptr: *Foo = try make(Foo, alloc)

where the signature is
make(comptime T: type, alloc: std.mem.Allocator) !*T

I know alloc.create exists but curious if there’s a reason there aren’t more globally avail utility functions like the above

Great point re default parameters in a struct. Will be using that for sure.

#1:
I didn’t mean that those functions would take a global allocator but rather the functions would be available without having to import std and get a valid allocator instance before create() is in scope.

#2:
Would there be any downsides to making these avail as they seem to be used as params in over 50% of functions

i think the point of needing to have an allocator is making explicit the need to consider an allocation strategy. these suggestions make that need less explicit, so i think they’re unlikely to land.

1 Like

I get that regarding implicit allocators in function signatures and default params, but the make() / destroy() functions I am suggesting would still require an explicit allocator to be passed it would just help unify creation / deletion of allocated types.

ex.)
std.ArrayList(T).initCapacity(alloc, 2)
vs
alloc.create([2]Foo)

could be simplified to
make(std.ArrayList(T), alloc, 2)
and
make(Foo, alloc, 2)

You might not want the standard library. This case is common in embedded systems.

Yes, that is correct.

Because the call alloc.create(T) and make(T, alloc) are the same, and there is no reason to add a global function that its implementation is:

pub fn make(T: type, alloc: Allocator) T {
    return alloc.create(T);
}

that really does nothing.

The reason to add a make method is when additional initialization is required.
In zig normally we call them init when returning a struct or create when returning a pointer to struct.

3 Likes

also, idiomatic zig code does not call allocator.create very frequently, in my experience

4 Likes

Yes of course I didn’t mean to wrap that exact function in a new signature, but rather to replace the underlying implementation with a function that is not a behavior of the allocator type.

And got it re the std not being auto included for embedded. I know absolutely zero about embedded so taking your word for it.

There is a misunderstanding on how zig works in your example.

std.ArrayList(T).initCapacity(alloc, 2) and make(std.ArrayList(T), alloc, 2) are not equivalent.
The former will initialize a struct where a field items will be assigned the value of alloc.alloc(T, 2). The latter (assuming go make semantics) will allocate a slice with two std.ArrayList both uninitialized.

alloc.create([2]Foo) will create a single pointer to an array of 2 elements and arrays in zig are values. make(Foo, alloc, 2) would be the same as alloc.alloc(Foo, 2) a slice of two elements.

I don’t think the unification you hope for can work in zig beyond what the allocator interface offers. As zig doesn’t have builtin dynamic array and hash map, so such global functions won’t work properly.

2 Likes

If these things impact your user experience significantly, it means you are dynamically allocating too often.

3 out of the 4 suggestions are about introducing things implicitly. Zig has this absolutely beautiful feature, which makes it one of the most readable languages: every name has a declaration in that file.
If you see foo = 3; anywhere, you can guarantee there is a var foo in that same file, which explains exactly what foo is. Coupled with the absence of shadowing, it makes everything obvious and ergonomic, specially when coupled with “go to definition” from an LSP.
By far, the worst, most frustrating experience of using C and C++ is not being able to figure out what type something is, because it’s hidden in 1 #include out of 1000, and even your LSP has given up.

2 Likes

It’s quite inevitable for libraries trying to provide a relocatable handle-like values when you try to keep stable references in inner object graph. Otherwise it leads to fragile unsafe API. stdlib could take a stance “just use it correctly” but it’s rarely an option for other libraries.

It’s funny how programming in Zig made me finally internalize why move-semantic is such an important topic.

Regarding why many of the std APIs are not in the global namespace, and why there are no wildcard imports, you might be interested in this article:

In short, it reduces context in the parser and allows the compiler to make more assumptions on a per-file basis. The compiler can know more detailed information about the dependencies between files, and can prevent needing to re-parse entire files, modules etc for incremental updates.