Zig pipe operator

See: https://www.reddit.com/r/Zig/comments/1pf47kg/idea_pipe_operator/

Edit: fix the link

I’m not a reddit user, so maybe I don’t get it, but the link just takes me to the zig reddit page.

My initial reaction to this is that it doesn’t really make sense for Zig. Don’t get me wrong, I love the pipe operator, (big nushell fan), but it opens a serious can of worms.

First of all, if you introduce a pipe operator, you’ll inevitably also need to introduce a protected variable name to represent the piped content, for times when you need to reference it by name, something like it or in.

Example: Produces <foo> in nushell:

'foo' | $'<($in)>'

A more illustrious example, albeit not real code:

'foo,bar' | str split ',' $in

Adding one operator and locking down one variable name doesn’t sound like much of a burden, but in a super streamlined language like Zig, every character counts.

More importantly, the pipe operator exists to make functional programming easier. Functional programming, as sexy as it can be (yes I do like haskell as well), is not really conducive to low-level code, and Zig has taken a position that while FP should be possible, friction has been put in place to make you think twice about whether or not you’re making the right choice.

If you don’t believe me, try making a higher order function in Zig - it’s possible, but you can practically hear the compiler’s disapproving look :joy:

1 Like

I think this is the Link they were trying to reference: https://www.reddit.com/r/Zig/comments/1pf47kg/idea_pipe_operator/

OP, please correct me if I’m wrong

2 Likes

Yeah that’s the correct one.

const y = val |> @intCast |> @as(u32, _);

…this doesn’t look any better than the nested version tbh. The problem is the required casting noise, not some other syntax details. Also instead of a new operator it could just as well be handled via the dot-operator if Zig had UFCS support:

const y = val.@intCast().@as(u32);
1 Like

This kind of thing will probably never be added to zig because it is minimalistc to a fault.

I personally don’t think that “just use variables” is the best for every situation, but even if you’d try to argue for that you will just hit “Avoid local maximums” from zig zen. Variables are technically good enough for everything.

The really nice thing about piping though is that it can make nested expressions read left-to-right, which is just much nicer to read in a lot of situations. I do like it in languages that support it.

I would really like if Zig would have UFCS.

Right now the exists some kind of duality between functions. Those that are defined in the scope of the type of the first parameter and others that aren’t.

The first ones can be called by foo.bar() while the the others can’t. This also means that (without changing the source) you can’t extent a type with a function and just call it “normally”.

One case where I usually have this when creating functions that act on on some kind of ArrayList (or the slice) or other std type.

The main Argument against UFCS I see is that you can’t just read struct definition and be sure that those are all the functions, that can be called with foo.bar(). Meaning it hurts explicitness. But this is easily solved by modern tooling.

1 Like

Please define the term “UFCS” for me.

1 Like

I think the devil is in the details with UFCS. For one, Zig would need to support ADL to support it, which, given C++’s track record in this area, probably introduces more complexity to the compiler than is worth it. Take this currently valid code for example:

const foo: Foo = .init();

Without UFCS, this is unambiguously a call to the function member Foo.init. But with UFCS, it could be some other non-namespaced init with an anytype as the first parameter.

I believe UFCS has been shutdown in the C++ committee for similar reasonings. Chances are, if C++ committee thinks it is too complex, it is probably best avoided.

1 Like

Here is a link for the wikipedia site

The paper I had in mind.

Thanks for the references. Turns all function call syntax into method-call syntax. C# does this, called “extension methods” (IIRC - stopped writing C# when I retired).

Back in the days (before 0.11, I think), @intCast() accepts a type as an argument. Bringing that back under a different name (@intCastAs()?) seems a more straight-forward solution. For pointer casting, I wonder if it wouldn’t make more sense to make @ptrCast() accept an options argument in lieu of @alignCast() and @constCast(). So something like this:

const a_ptr: *A = @ptrCast(opaque_ptr, .{ .@"align" = true, .@"const" = false });
1 Like

The Reddit OP is saying this would make the “nested casting less annoying”. Are people really casting things that often?

If you’re working with C libraries I can see you might need a layer of type management around the calls. That’s why C pointer coerce so easily. However, whilst you’re in one language, casts are a code smell in my book. It’s a sign you’ve chosen the wrong types. You’ve got types encoding information which you’re quite happy losing in the cast (e.g. signedness, constness, a pointer’s domain). So if you’re happy losing that information, why use that type?

3 Likes

I think casts is quite important. I believe that explicit type conversions help write safer code, while Zig uses implicit conversions in some places (for example, converting from a lower bit number to a higher bit number), which can introduce some potential footguns:

In addition, using enums and packed structs helps to isolate integers of the same length but with different meanings, which improves type safety. However, since these types do not support native operations, a lot of type conversion code, such as @enumFromInt, also needs to be written to handle them. Furthermore, because T{} is discouraged, more and more @as(T, .{}) will appear in the code.

Although there are some scalability issues, the current approach is logically consistent in Zig. Zig unifies types and namespaces, so there is no need to maintain both the ‘virtual’ namespace of a class and the actual namespace as in C++. ADL in C++ is a disaster, making it difficult for developers to determine which function they are calling just by looking at a single namespace.

One acceptable approach I can think of is a ‘declaration extend type’, which constructs a new type based on an existing type, ensuring its ABI is consistent with the base type. It inherits the existing declarations of the base type, prohibits adding new fields, allows adding new declarations but forbids overloading. When used, the extend type can be type coerced with the base type (not between different extend types of the same base type, but conversion can be indirectly achieved through the base type).

However, in practice, a similar effect can already be achieved with single-field wrapper structs, so the approach I thought of may not be worth adding.

1 Like

The pipe operator gets around much of the implicitness of the extension type / UFCS stuff - the presence of the pipe indicates the externality of the function while still allowing it to be placed to the right of its first argument.

The syntax definitely takes getting used to, but it can make linear operation chains easier to read by converting nested operations into a sequence of operations.

Realistically, the current ways of doing things are either operation nesting (good for tree-like operations, but linear chains of operations read right to left), or several one-off intermediate variables (possibly in an anonymous scope; naming these isn’t always easy, especially if it’s conceptually the same data, just going through a sequence of transformations).

Single field wrappers are similarly explicit to pipes, but have more syntactic “inertia” than a pipe: you’ll generally have to unwrap/rewrap each intermediate value before and after every external function call, with no additional explicitness beyond what a pipe operator offers.

2 Likes

Totally agree. Type conversions do need to be explicit when they have potential consequences (e.g. losing information). My point was that type conversions shouldn’t be needed very often. If you’re constantly switching types you’re probably using the wrong type.

IME yes, as soon as you actually use Zig’s flexible-width integer type, or need to mix signed and unsigned integers it’s casting galore. If you only ever use usize it’s mostly fine though.

I wrote about that a bit here in my home-computer-emulator blogpost (search for Bit Twiddling and Integer Math can be awkward). Many of the required casts could be inferred by the compiler though without giving up the idea that Zig doesn’t do any implicit casting that would lose information.