A not-immediately-fatal way of indicating that a feature is unavailable

In many places in the standard library, you see the use of @compileError() as a mean to indicate that a feature is unavailable for one reason or another. Sometimes it’s a function:

pub const min = @compileError("deprecated; use @min instead");

Sometimes it’s a type:

        pub const Writer = if (T != u8)
            @compileError("The Writer interface is only defined for ArrayList(u8) " ++
                "but the given type is ArrayList(" ++ @typeName(T) ++ ")")
        else
            std.io.Writer(*Self, Allocator.Error, appendWrite);

The presence of these @compileError() calls make it impossible to scan the structures in question. As soon as you touch the decl it blows up on you. A better mechanism is really needed, I think, that would allow meta-programming code to detect such instances in order to avoid them while at the same time getting the message across to programmers.

1 Like

I am not getting the problem. How do you scan or touch the decl?

For example:

const std = @import("std");

pub fn main() void {
    const T = std.fs.Dir;
    const decls = @typeInfo(T).Struct.decls;
    inline for (decls) |decl| {
        _ = @field(T, decl.name);
    }
}
/home/cleong/.zvm/0.13.0/lib/std/fs/Dir.zig:2386:24: error: deprecated; renamed to writeFile
pub const writeFile2 = @compileError("deprecated; renamed to writeFile");
                       ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3 Likes

I think it’s a really concerning issue. I consider metaprogramming capabilities a very important aspect of programming languages. Compile time introspection is an integral part of it. This style of using @compileError may be convenient to point the user to the right direction, but it destroys generality. Now it’s impossible to pass arbitrary type to a comptime function that does some introspection. And there is no workarounds for that.

For similar reason I also consider the approach if (comptime condition) T else void somewhat harmful. Considering this case may complicate the generic function depending on its introspection algorithm. But at least you can work with that, contrary to the @compileError approach.

I understand that bringing features from another languages without solid reasons is a bad design, but I just want to share how the D language deals with it. D is quite metaprogramming-heavy and it’s considered as a strong side of this language. Maybe it will inspire you to come up with a more appropriate design for zig.

If some field/declaration may be optionally present in a container depending on some comptime condition, the usual approach in D is to use static if.

struct A(T) { // T is a comptime-known template parameter
    static if (is(T == ubyte)) {
        public alias U = int;
    }
}

static if doesn’t introduce a new scope, so U is a declaration of the struct if T is ubyte, otherwise the struct doesn’t contain any declarations.

We can translate it to zig.

fn A(T: type) type {
    return struct {
        pub usingnamespace
            if (T == u8) struct{ pub const U = i32; }
            else struct{};
    };
}

So it’s possible in the current zig. Cons:

  1. Doesn’t print nice error message if the user erroneously tries to access U.
  2. A little bit verbose (but I’m totaly fine with that personally).

The question is what is considered a good practive within the zig community. Maybe we can discourage the @compileError approach in favour of conditional usingnamespace.

Another interesting thing that D has is __traits(compiles). The arguments of __traits(compiles) may be expressions, types, function literals (i.e. lambdas), etc. It is used, for example, to check whether a type satisfies some complex condition. This test could detect @compileError and just return false at comptime instead of breaking the build. But I’m not sure whether it’s a viable option for zig considering lazy compilation and caching.

I don’t think this is illustrating what you think it is. You’re using writeFile here, that’s why it’s a compile error.

Contrast with this:

test "avoiding @compileError" {
    const T = std.fs.Dir;
    const decls = @typeInfo(T).Struct.decls;
    inline for (decls) |decl| {
        if (@hasDecl(T, decl.name))
            std.debug.print("It has {s}\n", .{decl.name})
        else
            unreachable;
    }
}

This runs fine, because we aren’t emitting code which uses the bad decl. So introspection isn’t the problem.

Given that, it should be possible to special-case the decls which you know are bad, and write the rest of them into an inline for loop.

It might be useful to have a comptime built-in which can tell whether a decl or other field will trigger a @compileError, but I think just discovering them the hard way and excluding them from the comptime code is acceptable here.

With a bit of banging on the problem, I ended up with this:

test "avoiding @compileError" {
    const T = std.fs.Dir;
    const decls = @typeInfo(T).Struct.decls;
    inline for (decls) |decl| {
        comptime if (!std.mem.eql(u8, decl.name, "writeFile2")) {
            _ = @field(T, decl.name);
        };
    }
}

I don’t actually think we need additional features here. This is comptime-only, and extends to all cases of avoiding using a decl which will trigger a @compileError, which you have to do anyway.

If an update means that another field is now a @compileError, whatever code was using that field is broken, so the comptime if statement will need to be updated. In real code, whatever field you were using is presumably a load-bearing part of the resulting function, so you wouldn’t actually want an introspection tool to automatically leave something out, that would just produce silent breakage in a bad way.

No, I’m not. I’m just retrieving the function. The following, for instance, compiles just fine:

const S = struct {
    pub fn a() !i32 {}
};

export fn nothing() void {
    inline for (@typeInfo(S).Struct.decls) |decl| {
        _ = @field(S, decl.name);
    }
}

Here S.a() is, of course, an invalid function. There’s no error because it’s never actually called and the compiler has no need to compile it.

I think we’re misunderstanding each other. In both cases you create a comptime reference to a decl. In the first case you provided, any such reference is a compile error. In the second case, it isn’t, so it gets optimized away.

It’s no different from this:

fn addFive(addTo: usize) usize {
    return addTo + 5;
}

test "no problem here" {
    if (false) {
        _ = addFive(5.0);
    }
}

Zig compiles lazily, so the compile error doesn’t arise. But in your example, you’re triggering the compile error, because it’s illegal for code to refer to that field.

My point is that if you don’t create a reference to something which throws a compile error, no compile error will result. As I illustrated, you can look at the decl, and thereby avoid using it.

Can you show me something where this wouldn’t be sufficient? I’m not seeing an application where creating a reference which is going to blow up on you is useful. This isn’t an introspection problem, this is a “create a reference to a field which is an immediate compile error to reference” problem.

Your original example does something more like this:

const do_not_refer = @compileError("I told you: do not refer!");

test "oops" {
    if (true) {
        _ = do_not_refer;
    }
}

The optimizer would remove that, because it doesn’t do anything, but it doesn’t have to: you can’t refer to it, or the line gets compiled and the @compileError is triggered.

This works because you know the type you are inspecting and all its “bad” declarations. But imagine you are writing a generic library, which dumps all declarations, serializes them or something like that. What do you do then? You can’t know in advance what types the user of your library will pass to your inspection code.

1 Like

Instead of using comptime for language / source code analysis like this, you might want to use the std.zig.Ast and parse the source directly. Comptime is meant for meta-programming, not actually scanning big chunks of zig code. It will be much faster to do this runtime rather than comptime as well.

1 Like

Even for simple tasks like iterating over declarations?

  1. Why does introspection exists in zig then?
  2. It’s like a really big gun for such a simple task.

Upd: is AST enough for this rask? You need full semantic analysis to determine whether something is a compile error or not. Does std.zig.Ast provide such information?

Yup. What we need something like a @compileDecl():

inline for (decls) |decl| {
    const decl_value = @compileDecl(T, decl.name) catch continue;
    // do stuff with decl value
}

That’d would require the least amount of changes to the language and cover other scenarios where compilation would fail aside from @compileError().

My initial reaction is that this is an example of something which happens to not be possible or easy, rather than a useful thing which the status quo blocks.

As in, I don’t think this category of software actually exists. You can fetch the names of every decl, and their type data, and serialize that. But what is the actual application for compiling a reference to everything without any awareness of what you’re compiling?

I could be persuaded, but I’m not seeing it.

Alright, I’m starting to see the issue here, because you can’t do anything like this:

const hmm = struct {
    a: u64,
    pub const doNotEngage = @compileError("can't use it!");
    pub fn bazBux(a: u64) void {
        _ = a;
    }
};

test "can't touch this" {
    const info = @typeInfo(hmm);
    inline for (info.Struct.decls) |d| {
        const decl_info = @typeInfo(@field(hmm, d.name));
        std.debug.print("name: {s}\n", .{d.name});
        std.debug.print("type tag {s}\n", .{@tagName(decl_info)});
    }
}

That’s worth fixing. I had assumed, incorrectly, that the type of a Declaration would be accessible from the type object, but there appears to be no current way to introspect your way to a field type from the container type, you have to go through a reference to the object.

The fix would be pretty straightforward: add a builtin @fieldType(val, str); which retrieves the type from the field name. Unlike @TypeOf(@field(val, str)), this wouldn’t create a field access, and would provide the type of the Forbidden Decl, which is presumably of type NoReturn.

Alternately / concurrently, add the type of a Declaration to that Declaration. A StructField has the type, so you can do this:

const hmm2 = struct {
    a: u64,
    b: f64,
};

test "introspect field" {
    const info = @typeInfo(hmm2);
    inline for (info.Struct.fields) |f| {
        const field_info = @typeInfo(f.type);
        std.debug.print("name: {s}\n", .{f.name});
        std.debug.print("type tag {s}\n", .{@tagName(field_info)});
    }
}

Having written code which introspects on the type of a StructField, I didn’t actually realize that wasn’t possible for Declarations, so I didn’t get what the problem is. But they’re just names. That seems like an oversight which should be remedied.

Not to pick on you @chung-leong, because you raised an important issue, but the example you gave to illustrate it was in fact something which shouldn’t work. I agree that this is a problem, unless I missed some trick for introspecting a decl’s type.

Relevant proposal: Prevent @typeInfo and @TypeOf from triggering compile errors

4 Likes

@cImport() also creates a lot of calls to @compileError(). I have the following snippets:

pub usingnamespace @cImport({
    @cInclude("stdio.h");
});

That leads to a cimport.zig in the cache directory containing 75 calls to @compileError(). Examples:

pub const __INTMAX_C_SUFFIX__ = @compileError("unable to translate macro: undefined identifier `L`");
// (no file):95:9
pub const __UINTMAX_TYPE__ = c_ulong;
pub const __UINTMAX_FMTo__ = "lo";
pub const __UINTMAX_FMTu__ = "lu";
pub const __UINTMAX_FMTx__ = "lx";
pub const __UINTMAX_FMTX__ = "lX";
pub const __UINTMAX_C_SUFFIX__ = @compileError("unable to translate macro: undefined identifier `UL`");
// (no file):101:9

And

pub const __glibc_macro_warning1 = @compileError("unable to translate macro: undefined identifier `_Pragma`");
// /home/cleong/.zvm/0.13.0/lib/libc/include/generic-glibc/sys/cdefs.h:653:10
pub const __glibc_macro_warning = @compileError("unable to translate macro: undefined identifier `GCC`");
// /home/cleong/.zvm/0.13.0/lib/libc/include/generic-glibc/sys/cdefs.h:654:10
pub const __HAVE_GENERIC_SELECTION = @as(c_int, 1);
pub const __fortified_attr_access = @compileError("unable to translate C expr: unexpected token ''");
// /home/cleong/.zvm/0.13.0/lib/libc/include/generic-glibc/sys/cdefs.h:699:11
pub const __attr_access = @compileError("unable to translate C expr: unexpected token ''");
// /home/cleong/.zvm/0.13.0/lib/libc/include/generic-glibc/sys/cdefs.h:700:11
pub const __attr_access_none = @compileError("unable to translate C expr: unexpected token ''");
// /home/cleong/.zvm/0.13.0/lib/libc/include/generic-glibc/sys/cdefs.h:701:11
pub const __attr_dealloc = @compileError("unable to translate C expr: unexpected token ''");
// /home/cleong/.zvm/0.13.0/lib/libc/include/generic-glibc/sys/cdefs.h:711:10
pub const __attr_dealloc_free = "";
pub const __attribute_returns_twice__ = @compileError("unable to translate macro: undefined identifier `__returns_twice__`");
// /home/cleong/.zvm/0.13.0/lib/libc/include/generic-glibc/sys/cdefs.h:718:10

It’s really a crippling issue.