Why is declaration publicity a feature of Zig?

It seems that this goes against a lot of arguments presented against struct field publicity, and yet it is still a feature of the language. I’m honestly just asking because there are some functions in the standard library that I would love to use, and yet aren’t marked pub, so I am forced to copy them into my code. I see no reason why private declarations should exist.

5 Likes

I think the main reason why declarations can be private is you can just copy them if you need to.

You could not do the same with fields safely due to many types not having a stable layout in zig.

The benefits of private things are deemed to be worth the cost for declarations but not fields.

2 Likes

the main benefit is to exclude them from your API, which allows you to change them without a breaking version as long as the public API is the same (signature and behaviour).

Many do argue that this ability is worth the cost for fields, where you can’t safely get around it.

1 Like

I just really really don’t wanna have to write 30 no-op functions that are already RIGHT THERE

1 Like

If you are copying them it shouldn’t be too bad, unless they are scattered around :skull:

I assume you are talking about Io interface on master? I would expect them to provide public no op functions eventually, they do for Reader and Writer.

I’d guess it’s due to there practically only being one Io in std atm.

Well, I tried to address that.

I think a much better solution would just make some public no-op/panic implementations, probably in the vtable struct so to not polute the Io namespace.

Reader/Writer for example do have both public no-op implementation functions and even default implementations. Though they didn’t put them in the vtable namespace.

1 Like

One surprising effect of pub fns is that @hasDecl only “sees” the public API of any struct, even if the container’s scope would allow full access. I think this is nice to have, because you can now define invisible helpers or quickly hide a public item from the systems using such reflection during development.

1 Like

I definitely agree ! When implementing reflexion for my toy VM, having an API struct with private helpers was nice to have. Otherwise I would have put them somewhere else which is not as nice.

I get that, but I want to answer your question as written in the title, which is somewhat orthogonal.

Why is declaration publicity a feature of Zig?

Because it allows you to safely write functions that could otherwise cause crashes.

For example, I can write functions that have unreachable switch prongs, unwrap options or catch errors with catch unreachable because I know all places that this function is called in and can be certain that certain states are guaranteed to be impossible in those places.

Usually these states depend on complicated runtime behavior, so it is impossible to prove with the type system, but I as a human can promise that I know that something can’t happen, so the compiler doesn’t have to generate code for it.

Zig’s tagline is:

Zig is a general-purpose programming language and toolchain for maintaining robust, optimal and reusable software.

And the goal is not that you can pick two of those three, it’s that you get to have all three at the same time.

So the reason why this feature exists is to allow developers to write an optimal and reusable solution to a problem inside of a module in a robust way, all at the same time.

If my hypothetical function implementation that relies on unprovable runtime invariants was public, it would still be optimal and somewhat reusable, but it wouldn’t be robust. To make it robust, I would have to manually inline the function, which duplicates code, making it no longer reusable, or I would have to add additional runtime checks, making it no longer optimal.

6 Likes

It seems that in general, Zig prefers “please don’t do this” to “you may not do this”, so why wouldn’t it make more sense just to add a private namespace (in userland), so that private code is still accessible, even if it’s use is heavily discouraged?

2 Likes

At that point it is mostly preference.

I think the deciding factor could be people!

A “private” namespace, even if documented clearly as unstable, is still public and people may still depend on it and still use it (unconsentually) being public nature as justification.

Actually private code, that requires you to copy it into your own project adds friction and defeats the silly argument above.

All of this is our own speculation, perhaps a team member will chime in with the actual reasoning, but that’s unlikely.

FWIW, some of the core team members (I’m not naming names, because I can’t remember who exactly) agree with you and think pub qualifier should be done away with. I don’t know if it’s really an open question anymore though.

3 Likes

Small update regarding that thing: I have a (currently open) pull request for a Io.failing interface (which is not completely unusable; see doc comment for it) waiting for review…

2 Likes

Zig generally encourages an explicit, step-by-step imperative style rather than a chained, functional style where everything is packed into a single statement.

E.g. compared to:

pub const final_result = (some_input * 2) + @as(u32, @intCast(process_data(raw_data)));

Zig prefer:

const step_a = some_input * 2;
const step_b_raw = process_data(raw_data);
const step_b: u32 = @intCast(step_b_raw);

pub const final_result = step_a + step_b;

It is imaginable that, without the pub keyword controlling the visibility of symbols, many developers might deliberately use styles that Zig discourages in order not to expose these symbols in structs.

After introducing block expressions, this problem can be solved in certain situations:

pub const final_result = blk: {
    const step_a = some_input * 2;
    const step_b_raw = process_data(raw_data);
    const step_b: u32 = @intCast(step_b_raw);
    break :blk step_a + step_b;
};

But when multiple declarations want to share these intermediate symbols, there is still a problem because tuple destructuring cannot be used within containers.

However, I think a very compelling point is that once an intermediate symbol needs to be shared by multiple declarations, it is highly valuable to expose it rather than keep it hidden.

In fact, I often get frustrated that the structures in the standard library don’t expose some key declarations, making it difficult to extend certain implementations.

Therefore, I think the existence of pub was very necessary before Zig introduced block expressions, but now there are doubts.

2 Likes

Even without the pub keyword, you would still have other roundabout ways to hide functions and other declarations:

pub fn foo() void {
    const T = struct {
        fn bar() void {
            // Not exposed to the user.
        }
    };
    T.bar();
    
    @import("private_implementation_details.zig").baz(); // Neither is this.
}

So removing the pub keyword would be kinda pointless and not really change anything.

3 Likes

The first approach is suitable for hiding functions that are called only within a single function—I think this kind of hiding makes sense, as such functions do not really need to be public. However, if a second function needs to call bar, this kind of hiding does not work—I also think this makes sense, as such functions should not be hidden.

I believe the second approach doesn’t really hide anything; in fact, everyone can access baz.

Not everyone can. Code within the same module can directly import the file, but any external dependents of a module can only access pub declarations that are reachable from the root source file of said module (and if you try to be clever you will get a “file exists in multiple modules” compile error). So it’s less private than omitting pub internally within the module but just as hidden externally.

1 Like

This is my knowledge blind spot! I naively assumed that the same file could be interpreted differently by different modules. Thank you for clarifying my misconception.

The boundary where private things become private is files, not modules.

The way I think of it is: a type can access the private declarations of any other types defined inside of it, but @import doesn’t define the file, it just refers to it the same way you refer to any other type. When you assign an import to a const you are just making an alias like you do with other types.

1 Like