Understanding std paths in Zig: how to tell package vs module vs types vs values vs aliases vs other items?

I’m trying to better understand how to read Zig’s std documentation and navigate paths like:

std.Io.Operation.FileWriteStreaming.Result

If possible, could you break down what each part is in the example above?

My confusion/difficulty is about identifying what each part represents. For example, in such a chain, how can I tell whether something is: a package or a module (file), a field (member), a type (struct/union/enum), or a type alias? Even a package , a module and their namespacing confuses me.

I noticed everything is accessed via dot (.) in Zig, but I’m not good in how to interpret each segments without jumping into the source code all the time.

Is there a reliable way or mental model to understand what each segment refers to without always jumping into the source? What helped you ?

I want to improve reading codes like the std libs which will help me be comfortable Zig. Thanks

std is a module, the line const std = @import(“std”); imports the module std and names it std within your code. this is a convention, of course. you could name it whatever you want.

every module has a root file. if I remember correctly, the root source file for std is lib/std/std.zig in the Zig repo or in a release that you download. but! you don’t have to take my word for it: I believe you could find that information for yourself by reading the build.zig script in the Zig repo.

anything accessible via std.whatever corresponds to a public declaration (or a field) in that root source file. For example, I believe the following line is present in std.zig:

pub const Io = @import(“Io.zig”);

(sorry for the non-ascii quotes lol, I’m typing on an iPad)

The Zig standard library follows the convention that types (which may be declarations within a file or files themselves) that are instantiable are uppercased. Zig types also double as namespaces.

So, std.Io.Operation.FileWriteStreaming.Result means that there is the following logical structure (although in practice many of the = struct {} lines are in fact = @import() lines and could be = enum {} or = opaque {} or what have you instead)

pub const std = struct {
    pub const Io = struct {
        pub const Operation = struct {
            pub const FileWriteStreaming = struct {
                pub const Result = struct {};
            };
        };
    };
};

Zig used to have usingnamespace as a keyword for creating “mixins”. Now that it does not, you can be assured that the location of the code you are referring to with a fully qualified type may be jumped to using a deterministic process that may involve renaming / redeclaration or indirection through @import(), but is not more complex than that.

5 Likes

A package

is what you put as you dependencies, usually your project would be a single package, but larger ones could be multiple packages. A package could be any git repository or archive and is not restricted to only zig dependencies.

A zig package

is a package with a build.zig.zon. That communicates more information to the package manager such as name, version, what files/dirs are part of the package (and what is not by omission) etc.
note that a build.zig is optional, but is how packages would export modules, artifacts etc. Without it dependents only get raw files/dirs.

A module

is purely a build system concept. It is a tree of source files (zig, c or cpp), linked objects, includes, and imports of other modules (imported modules must have a root zig file since imports are a zig concept). Modules can be exported which is the preferred way to reuse zig code.

std and builtin are modules implicitly added to your imports. Other modules have to be added explicitly.

files can only appear in one module in an import tree.

A compile artifact

is the actual executable, library or object; it gets its source via a root module and its imports. (all zig source for an artifact is compiled in one unit, unlike c/cpp source)

A type

is a description of data. There are some that are intrinsic parts of the language like: bool, f32, the variably sized ints, etc.
And user defined types: struct, enum, union, opaque.

A value

a value is actual data, it must have a type to describe its data. What is a mind bender is types themselves are also values (of type type), but this is quite powerful.

A namespace

In zig, user defined types (struct, enum, union, opaque) do double duty as namespaces. This includes files, which are just structs (yes, you can give them fields, and instantiate files).

Types which only function as namespaces will be snake_case, but (user defined) types that are intended to be instantiated use PascalCase (assuming the author follows the style guide, it is not enforced)
Since we are on the topic, functions use camelCase, and variables/constants use snake_case.

official style guide

An alias

Is just binding a name to a type, the first binding according to source definition of the type will be the canonical name of the type (excluding intrinsic types)

Accessing things

zig uses a.b as a universal access syntax, it could be a declaration on a type (const, var, fn). A field of a user defined type, or calling a function on an instance of the type the fn is defined in.

where a is a type, it can be ommited so long as type is provided to the expression in another way, e.g const a: Foo = .empty, or foo(.bar).

note that .a catch { ... } does not work, same with orelse, due to limitations in the compiler.


std.Io.Operation.FileWriteStreaming.Result
std - module and namespace
Io - Instantiable type and namespace
Operation - Instantiable type and namespace
FileWriteStreaming - Instantiable type and namespace
Result - Instantiable type

6 Likes

Come to think of it, the many meanings of the dot seem a bit contrary to the Zig philosophy :slight_smile:

The dot can separate namespaces (structs are also namespaces), access an item in a struct value, and access an item via a struct-pointer-indirection.

Not that I want the C++/Rust-style symbol salad back, but I had at least been missing the clarity of . vs -> in C (a -> in C is always a pointer indirection - e.g. when you see something like a->b->c->d all alarms should go off, while a.b.c.d has the exact same runtime cost as a.b.

In Zig when you see a a.b.c.d you need to actually dig into the type declarations to estimate the cost.

4 Likes

I think I prefer the simplicity of . for all kinds of accesses. (even if it has a bit of a learning curve about what it does in specific situations)

At least I am glad we can avoid the awful :: everywhere.

9 Likes

I also struggle with dissecting the std library to understand the intention behind how things are used / structured.

It’s normally around those points that I start losing the thread. I’d much rather levels of the library where instantiable types OR namespaces, not both. As capitalised names are types which can also be namespaces I never know if I can consider a type to be opaque (i.e. that I don’t need to worry what it contains).

For example, std.Io.Writer is a very useful type to know about, but generally I can treat it as opaque. I’ll be returned one from time to time. I’ll be asked to provide one sometimes. I’ll use it’s API. I don’t need to go deeper. As such, I would expect other types below std.Io.Writer to be implementation aspects of std.Io.Writer. Things which I wouldn’t need to know about and can ignore, like the VTable. However, the “in-memory” writer types live there. Types like std.Io.Writer.Allocating. That means I have to read everything under std.Io.Writer and work out if the types there are user-facing or internal.

I know AK has said in the past that he sees namespaces that are ONLY namespaces as redundant (paraphrasing), and maybe when you live in a codebase that’s true. As a consumer of a library, I’m really missing them.

2 Likes

TBH I am not sure why you would ever worry about what is in a namespace?

Do you also worry about the fields, functions, etc; in a “non-namespace” type? If so, then I don’t see why you would distinguish namespaces. If not, then why do you treat namespaces differently?

Knowing what a namespace/type has to offer is good for understanding its purpose and may be useful for future code.

But that is also entirely unnecessary! You can get by just searching std (other libraries, or even just the internet) for what you want, when you want it.

2 Likes

A type alias is a type, just by a different name.
In source code, a package or module will be a struct, which is a type.
So we are only left with how to tell apart a field from a type. In reality, this question is malformed. A field could be a type, and a type could be a field of some instance.
In zig, we have fields and declarations:

const T = {
  const declaration = 3;

  field: u8,
};

Declarations start with const or var, while fields start directly with the name.
Declarations belong to the type, while fields belong to an instance of that type:

T.declaration // ok
T.field // compilation error.
//Since T is a type, you can only access its declarations

const instance: T = .{ .field = 3 };
instance.field //ok
instance.declaration // compilation error.
// Since `instance` is an instance of T, you can only access its fields

You can have fields which are types:

const Foo = struct{
    field: type,
}

const foo: Foo = .{ .field = f32 };

Sinces types are comptime-only, an instance of Foo is only usable at comptime. These are used for metaprogramming. Most functions you’ll read will not use them.

Therefore, your question should probably be: “how to tell apart a field from a declaration?”. The answer is: “In foo.bar, if foo is a type, bar is a declaration, while if foo is an instance, bar is a field.”

Here’s a chaining example:

const T = struct{ f: u8 };
const U = struct{ pub const d: T = undefined; };

U.d.f
// From type `U`, we access declaration `d`
// From the instance `d`, we access the field `f`

Granted, in a long chain, you may end up not knowing if something is a type or an instance. Here’s a clue: at runtime, instances cannot access fields which are types. So, if the function you’re reading is not doing comptime stuff, an instance can only access fields, which will be instances themselves. So:

fn foo(a: T) void{
    a.b.c.d
    // If T is not some metaprogramming related
    // struct, all of these instances, and each
    // is a field of the instance before it
}
1 Like

Thank you all all for your helpThe :: syntax is explicit and clearly communicates its intent. Conversely, while . is concise, it looks to me it conflict with clarity.Currently, . is looks overloaded, handling namespaces, type access, and method calls. In heavily nested hierarchies, we should reconsider if it remains the optimal tool for the job.Coming from Go and Python, it took me about a year to feel comfortable reading Rust. In contrast, i was reading Zig fine within two months. For me this is a prove of Zig simplicity but I am having hard time understanding how code navigation works when looking for something in a lib and how the build system really works.I am not convinced yet if both case are really pleasing Zig philosophy.I want the :one obvious way, clarity and the most import thing to remember. That made me wander around Zig ecosystem. Thanks

Related to this discussion about structs vs namespaces vs whatever, if you compile the following program that has a slight error:

const std = @import(“std”);

pub fn main(init: std.process.Init) !void {
const io = init.io;

var stdout_buffer: [1024]u8 = undefined;
var stdout_file_writer = io.File.Writer.init(.stdout(), io, &stdout_buffer);
const stdout_writer = &stdout_file_writer.interface;

try stdout_writer.flush(); // Don't forget to flush!

}

you get this error message:

src\main.zig:7:32: error: no field named ‘File’ in struct ‘Io’

Looking carelessly at the documentation, it looks like struct ‘Io’ would have a field ‘File’ but of course File is a type in Io.

1 Like

there is one obvious way to access something because all things are accessed the same. I don’t have to think about if I am going through modules, types, namespaces or instances, I can just access what I want. I quite like this.

I am not sure what is making it hard for you to navigate, if you are inclined to share more?

There isn’t much point differentiating a module in source, since when you import it, you are importing the root file, which is a type/namespace. Maybe if you care about what is yours and what is from a dependency, but that should be obvious from the name, or namespaces you use to get to it, which you have control over!

It is useful to differentiate an instantiable type, which is done through the naming convention already.

I can see an argument for differentiating access to fields on an instance, but I don’t think it is particularly strong. I have used languages that do this (e.g. rust) and I am indifferent, I don’t have any issues using either.
If that is a significant issue for a non-insignificant number of people, then maybe it could change.

Considering Andrews stance on tabs, despite being objectively better for accessibility, and customisation for individuals, I don’t think this is likely to change.

2 Likes

“Worry” is probably the wrong word. I don’t want to have to learn about the contents of every part of the standard library to just be able to use one part. I’d like there to be clear distinctions between which types are intended to be instanced by me, the user of the library, and which ones are instanced only by the library itself. The second of those I don’t really need to know much about at all. I want to know where I can stop digging.

This concept could be communicated through structure (using namespaces) or by naming styles, or sections in the documentation. I don’t really care, but right now it’s all jumbled together and it makes understanding the standard library harder than it needs to be without someone explaining the intent in a guide.

1 Like

I get that that would be nice, but then you’d need a distinction for types that you need to initialize directly and types that should be init(). Then there should be a distinction for types that need deinit().
The type system can only go so far. Naming convention takes it further. But at some point, more information needs to be conveyed by reading documentation or even the source code itself.

1 Like

@vulpesx
It is difficult to master every API, especially when skimming for immediate needs rather than deep exploration. If we assume the current state is the best possible, then there is little reason for this discussion.

However, Zig is evolving & backward compatibility isn’t yet guaranteed so it might be beneficial to eventually pause and formally ask all its users: “What have you missed so far, or what would you like to see improved next?”
Having seen the trade-offs in various languages, I hope the core team can integrate the best ideas from across the ecosystem where it is technically feasible and know to work successfully.

My hope is that the core team maintains its momentum until the language makes almost all tasks feel simple. I’m excited to see Zig continue to improve over the coming years.

Ofc it is difficult, there are so many and they change often! I am merely saying you don’t need to if it is hampering you.

And that is not at all related to the current state, and not at all incompatible with looking to make it better!

If anything, trying to master them all will make it more difficult to improve even just one of them!

Do you have concrete suggestions as to how the language could convey more information in the source code?

Me too… the only catch is that if you want to find the source file the thing is in, you can’t necessarily just assume all the .s are directories and the last (or penultimate) is the file. Since structs can be in structs, and such, you don’t have an obvious way to the source file you need, if you want it for some reason. Thankfully, for std (and hopefully other lib documentation tends to follow suit), you can just click the ‘src’ at the top of some bit of the documentation and it takes you to the source code directly.

1 Like

This is not true, and actually you give the reason yourself in the next sentence: to make it explicit, though, if something is named a.b.c.d.e.f, the one and only path to find it is as follows (now that usingnamespace is gone)

  • jump to a. there is a public declaration or a field within it named b.
  • if it is a declaration, either its definition is in that file, or there is some minor indirection like pub const b = @import(“blah”).name
  • if it is a field, its type is a declaration which may also be followed in the same manner
  • in either case you can now jump to b or @TypeOf(b).
  • now you note that c is either a public declaration or a field on b, and the process repeats.

And with an editor with LSP integration, you can just skip to the end
assuming the definitions have simple, or no, comptime logic

1 Like

The dot (.) is working double-time which is good for consistency but having some sort of namespace opertator helps to navigate the “map” of a project much faster and help to prevent ambiguity.

Once you see it, you can’t unsee it and for that visiting few common places may help to compare and recognize certain easy ways. May be I need to be patient until its ways clicks for me as well.