1. 25
  1.  

  2. 6

    This is another instance of using compile-time metaprogramming to provide genericity features. This is touted as a strong point of Zig, instead of “complex type-system features”, you have the unifying ‘comptime’ approach. But, as someone unfamiliar with using Zig in practice, I’m left with many questions. Intuitively it sounds like having proper type-system features for genericity (for example, being able to express in the language / type system that I expect the |case| argument to have a talk() method, and maybe give the type of this method) has many benefits, including for usability.

    What happens, for example, when there is a type error in the comptime-expanded code? Maybe some of the talk() function calls generated are valid, but some of them are not. How do you talk about the error to the user?

    Another example: where can they read about the expected interface, what they need to support if they extend the union with other cases? (In C++ speak: it looks like this provides templates that are expanded without any error-checking beforehand, rather than concepts that express checkable requirement on the definition.)

    Language design is a matter of compromise and this one is interesting. It gives up on some safety, in exchange for a much simpler type system, which certainly has other benefits. But when I see comptime discussed, I rarely see a discussion of the actual costs to language users.

    1. 7

      But, as someone unfamiliar with using Zig in practice, I’m left with many questions.

      What happens, for example, when there is a type error in the comptime-expanded code? Maybe some of the talk() function calls generated are valid, but some of them are not. How do you talk about the error to the user?

      Maybe you should let your curiosity tempt you and try out Zig :^)

      fn makeTalk(foo: anytype) void {
          foo.talk();
      }
      
      const Bar = struct {
          pub fn scream() void {}
      };
      
      pub fn main() void {
          makeTalk(Bar{});
      }
      

      This currently produces:

      test1.zig:2:8: error: no field or member function named 'talk' in 'test1.Bar'
          foo.talk();
          ~~~^~~~~
      test1.zig:5:13: note: struct declared here
      const Bar = struct {
                  ^~~~~~
      referenced by:
          main: test1.zig:10:5
          callMain: /home/kristoff/zig/build/lib/zig/std/start.zig:604:17
          remaining reference traces hidden; use '-freference-trace' to see all reference traces
      

      Note how the first line in the reference points at the point where makeTalk was invoked, providing extra context.

      or example, being able to express in the language / type system that I expect the |case| argument to have a talk() method, and maybe give the type of this method

      This is how I do it in my Redis client (where you can implement custom types that know how to (de)serialize themselves). It’s a bit verbose and hopefully in the future we’ll have tools to help streamline some of this work, but it’s pretty straightforward and it gives a decent experience to users of the library:

      https://github.com/kristoff-it/zig-okredis/blob/master/src/traits.zig

      This is how the traits are checked in the serializer:

      https://github.com/kristoff-it/zig-okredis/blob/master/src/serializer.zig#L28-L31

      1. 2

        I noticed that the example didn’t use the inline else like in the article, so I went and tried it myself. Turns out, it does basically the exact same thing for the error (though, it does apparently only report the first error if there are multiples). No surprise there.

        The part that I found surprising, though, was that there was no error if the function wasn’t called. Does it not type check dead code? If so, is that by design?

        1. 3

          Ah sorry, I should have picked a more representative example.

          The part that I found surprising, though, was that there was no error if the function wasn’t called. Does it not type check dead code? If so, is that by design?

          Functions that don’t get invoked don’t get semantically analyzed. Some details might change in the future but, generally speaking, lazyness is part of what makes comptime more natural to use.

    2. 2

      How do you implement plugins in Zig, where you have a pre-defined interface and you don’t know the concrete implementations in advance?

      1. 7

        If you mean at the code level, meaning an interface for which the user of a library can create new concrete implementations, we can do it with comptime ducktyping when monomorphization is ok, and we can also do vtables like any other language when dynamic dispatch is desired. One example of this second way of doing things is std.io.Reader.

        If by “plugins” you mean loading dynamic libraries at runtime with new types that fullfill an ABI interface, then Zig supports that too through the C ABI.

      2. 2

        Almost every sensible use of interfaces/traits/whatever I’ve seen is used to glue local code to foreign code. A union just isn’t equivalent or useful in this case because the side that defines the interface cannot see its implementation.

        1. 10

          I think that’s terminology difference. The post uses “interfaces” as less jargony version of “runtime polymorphism”, not as “open-world dynamic dispatch”.

          It does seems to add more confusion unfortunately, which I think is 100% excusable by the illuminating “About Interfaces” section, which very crisply captures the Zig way of thinking about this class of problems.