I ported a small C++ program to see how it would go

The best way to evaluate a language, IMHO, is to use it to get stuff done. I like porting this particular C++ program, it relies on the OpenSSL C library to do a fair amount of heavy lifting, and there’s no real way around it either, so it tests how well the language can handle calling legacy C code. Other than that, it hits the basic stuff, like how well it can open and read a file, how straightforward it is to print stuff out to the screen, etc.

Here’s the C++ code:

And here’s the Zig port of it:

My thoughts on the language. Verbose and quirky. Quirky in good and bad ways. I particularly find while-else loops amusing. My level of frustration never reached the point where it did with Rust. I’d write more code in this language, despite its flaws. I absolutely won’t for Rust.

4 Likes

A few notes on your code, there are some things that can be done in a simpler ways, if you wish to pursuit Zig further:

   // this has got to be the fugliest way of initializing an array
   // who thought this was better than memset()? seriously.
   var readbuf = [_]u8{0} ** READBUF_LEN;

This used to be a common pattern, but Zig has a better alternative now:

var readbuf: [READBUF_LEN]u8 = @splat(0);
   // not as bad as Haskell but still this is a lot of hoops to jump through
   // just to set up to print something to stdout. You know, it's either one line
   // in C (printf) or one line in C++ (cout). At least they're not making you
   // learn about Monads. I remember Java was absolutely horrible at this sort
   // of thing. The bar has been set low over the years. Rust is *slightly*
   // more straightforward than this but not by much
   var buf: [1024]u8 = undefined;
   var stdout = std.fs.File.stdout().writer(&buf).interface;

Zig has some helper functions for this. For a larger application I’d recommend to look at std.log, for debugging purposes there is also std.debug.print

   // this is the equivalent of checking for null. fugly.
   if (ctx_maybe) |_| {} else {
       return HashError.CreateHashEngineAllocFail;
   }

That is one way to do it, but I rarely use this option. Instead the orelse keyword is a much more convenient alternative in this use-case:

// Look, the pointer is longer optional, no need for .? everywhere
//             ↓
const ctx_maybe: *openssl.EVP_MD_CTX = openssl.EVP_MD_CTX_new() orelse return error.CreateHashEngineAllocFail;
errdefer { openssl.EVP_MD_CTX_free(ctx_maybe); ctx_maybe = null; }

Setting the pointer to null is unnecessary. The function is left afterwards, there is no code that even could access it.

Also I really do not understand why you chose to convert the pointer to usize in the end. Generally that shouldn’t be necessary.

the std panic() call fails to actually panic.

This is most likely a bug and should be reported so it can be fixed.

8 Likes

There are two bugs in this line of code. Explanation adapted from here:

Implementions of std.Io.Reader and std.Io.Writer rely on being able to use @fieldParentPtr to get a pointer to their containing struct (see here for my attempt at a different way of explaining @fieldParentPtr).

By doing std.fs.File.stdout().writer(&buf).interface you are throwing away the File.Writer struct containing the std.Io.Writer as a field, so when the functions in the File.Writer vtable call @fieldParentPtr it’ll be interpreting arbitrary memory as a File.Writer and lead to illegal behavior.

Additionally, you are taking a copy of the interface field, so even if you weren’t throwing away the File.Writer you’d still have problems (see here for more details on that)

One possible fix would be:

-    var stdout = std.fs.File.stdout().writer(&buf).interface;
+    var stdout_writer = std.fs.File.stdout().writer(&buf);
+    const stdout = &stdout_writer.interface;

Side note: you might be interested in changing main to return !void and replacing your many ... catch {} with try ...

5 Likes

That array init statement is much more readable. Checked that in.

I went with all that optional pointer stuff because when you import those C calls they all require - optional pointers. That code is fugly and it needs cleaning up. It works though :stuck_out_tongue:

Is there an alternative to pointer-to-void in zig than using usize? I’d rather pass up an opaque handle than expose imported C types. That has a nasty way of infecting all your code after a while. Granted this is a teeny tiny program but I have dealt with larger projects.

As far as std.debug.panic() goes, to repro, force a panic by feeding it a filename that doesn’t exist. It seems to do the right thing initially but then goes off into a doomloop somewhere in the guts of the std library.

That is a bug indeed. Forgive my unfamiliarity with the library. However, you’re making my design case for me - it’s too complex and prone to errors and misuse. Keep in mind I’m just trying to do what printf() does. I think Rust has some print!() macro? Swift has something similar and simple?

In any case, fix is checked in.

fwiw, the interface-copy mistake comes up frequently. Fortunately there’s a plan to catch this class of bugs: https://github.com/ziglang/zig/issues/2414

std.debug.print

Does std.debug.print still work if you build in release though? If it does, perhaps it needs to live in another package?

Yes, but note that std.debug.print will print to stderr, unlike printf - so it’s more like fprintf(stderr, ...)

1 Like

Other random bits:

  • You might want to rebase your repository to exclude the commits that accidentally committed .zig-cache/zig-out. The repository history is now ~64 MiB for a few source files.
  • orelse is your friend when working with optionals:
    const ctx: *openssl.EVP_MD_CTX = openssl.EVP_MD_CTX_new() orelse return error.CreateHashEngineAllocFail;
    // when you return an error, this runs
    errdefer openssl.EVP_MD_CTX_free(ctx_maybe);
  • No need for @intFromPtr/@ptrFromInt, can just pass around *openssl.EVP_MD_CTX or if you really want to erase the type you can use *anyopaque and @ptrCast:
pub fn destroyHashEngine(engine: *openssl.EVP_MD_CTX) void {
    openssl.EVP_MD_CTX_free(engine);
}

or

pub fn destroyHashEngine(engine: *anyopaque) void {
    const ctx: *openssl.EVP_MD_CTX = @ptrCast(engine);
    openssl.EVP_MD_CTX_free(ctx);
}
  • I’d personally replace all instances of panic() with normal error returning/try:
var hashbuf = meowz.finalizeHashEngine(heap, hasher) catch |err| {
    meowz.panic("Failed to finalize hash engine",err);
};

:down_arrow:

var hashbuf = try meowz.finalizeHashEngine(heap, hasher);

In Debug mode (or when error tracing is enabled via -ferror-tracing with zig build-exe/etc or .error_tracing = true in build.zig) if that function fails you will see this output:

error: FinalizeHashFinalizeFail
/home/ryan/Programming/zig/tmp/meowz/src/root.zig:50:9: 0x11305c9 in finalizeHashEngine (root.zig)
        return HashError.FinalizeHashFinalizeFail;
        ^
/home/ryan/Programming/zig/tmp/meowz/src/main.zig:84:19: 0x1130e20 in main (main.zig)
    var hashbuf = try meowz.finalizeHashEngine(heap, hasher);
                  ^

I’ve pushed a more complete set of changes that I’d personally make here (probably not exhaustive, just the stuff that stuck out to me initially):

4 Likes
  1. c printf, c++ cout and rusts print! are all buffered.
    the difference is they use a global buffer and locks, whereas zig leaves the buffer management to you, which does make your job a little harder but also gives you more control.
  2. zig put the fancy printing stuff in the Writer streaming interface, while almost all these languages do something similar they hide it from you, zig not hiding it makes it easier to change the destination you’re writing to, very useful for testing.
  3. intrusive interfaces are very error-prone especially if you don’t know you’re using one. zig has multiple accepted proposals that will help the compiler catch this kind of mistake, unfortunately they aren’t implemented yet. Cost of using a pre 1.0 language.
  4. you can make this easier by:
    • never storing the &impl.interface in a local var, preventing you from accidentally copying it
    • &.{} creates a 0 length slice you can pass in place of a buffer. Not all reader/writer implementations accept that but most do, they will specify in their docs if they have requirements and also will assert those requirements*

T coerces to ?T this includes pointers.