Alternatives to operator overloading: infix functions

So I’ve been thinking about how different zig users often ask for operator overloading for a while now, a bit after I started using it at around 0.13. It always gets shot down, leaving those users to think that their particular domain problems will only be taken seriously in c++ or rust. There may be more domains, but the most common ones I see are the game, math and gpu devs. In all these cases, the common underlying demand/itch is to be able to express matrix operations. It’s not to say that those problems aren’t solvable, there’s plenty of zig repos for gamedev and math libs that try to make things as elegant as possible with comptime and type inference, but that’s still a far cry from what operator overloading would provide. They look at how all the basic operators work on @Vector and want it to work on matrices too.

When faced the reality that there’s little probabilities of operator overloading ever happening, they generally ask for something like @Matrix. But then that’s shot down because, just off the top of my head, which operator do we use for cross product if we use * for dot product? And then the math and gpu people come in and ask for arbitrary dimentional matrices (tensors?). So that’s not going to work out either. Thinking about it a bit more, there’s quite a few issues. People will want to wrap/subclass those Points/Vectors/Matrices inside their own struct types, which will not inherit the operators. People will also want those types to have methods like “invert()”, “norm()” or “det()”, and while it’s probably feasable for the community to provide a good implementation in the stdlib for this, I wouldn’t want to burden the core team to maintain it. And then there’s all those other math fields that would be well served by their own math specific types or operator overloading and then it’s obvious that @Matrix would have just been another short sighted band-aid. I’ll give @Vector as pass because zig is a very cpu-centric language, with things like @branchHint and @prefetch and vectorization is probably here to stay for another 2 decades.

So more time passed, I worked a bit more with hy (and elisp, because emacs is the only usable editor for lisp-like languages), and I think something clicked in my head recently. Let’s take the addition operator as example, here are the ways we can express it:

a + b // grade school and common programming languages
sum(a, b) // basic function
a.add(b) // everything is an object / java style
reduce(0, sum, [a, b]) // more flexible function / functional style
add a b // asm style, result stored in b
(+ a b) // polish notation / lisp style
a b + // reverse polish notation / hp & swissmicros calc style

Now before anyone freaks out, I’m not advocating to lispify zig, I just want to show how much visual variance there is to express adding 2 numbers depending what you’re most used to. That brings me to the next thought which is that what people want is to be able to express their operations “grade school” or “academic equation” style, which really just boils down to infix style. So what all these operator overloaders want to do is to express their functions with infix operators. Hmmm…

The first quick thoughts is that all infix functions conveniently only take 2 parameters, left and right expressions, or lhs and rhs. Everything after this is shaky ideas, needs critique, which is why I’m posting this in the first place.

So my first reflex was to ask for an @infix builtin function. That way you could do:

fn add(lhs: T, rhs T) T {}
c = a @infix(add) b;

or

struct Matrix {
    // stuff...
    fn dot(lhs: Self, rhs Self) Self {}
}
m3 = m1 @infix(Matrix.dot) m2;

But then I realized that these functions might be hard to implement as functions, because they’re actually more like a comptime “macro” that desugars the previous examples to:

fn add(lhs: T, rhs T) T {}
c = add(a, b);

which while outside the capabilities of comptime, is also probably out not feasable to implement as a builtin function.

So the next idea was infix sigils (colon used as example, anything is fine), like so:

m3 = m1 :Matrix.dot: m2;

I feel that something like this is still retains the simplicity/readability goals of zig. Some doubts I have is I’m not sure how well this reads over longer equations, and also I’m getting c++ method and rust turbofish vibes because of the ::.

Something I did not want was “tagging” functions with decorators, such as:

@infix
fn add(lhs: T, rhs T) T {}

because then you end up with this sort of unreadability and compiler/parsing nightmares:

m3 = m1 Matrix.dot m2;

There would be restrictions of course, the first one being that infixable functions to take 2 parameters only, I think. I’m not sure if their types have to be comptime known. In regards to operator precedence, whichever is easiest for the zig ast parser, probably the weakest, people can use parens if they want to chain with builtin operators. I’m not sure if bias/gravity is needed, or we can just left hand side everything. Something I’m concerned about is if this violates some self-imposed language restrictions such as context free grammars, in which case my entire idea is a dead end, I’m not a programming language designer.

So yeah, that’s the meat of it. This would allow math and other based domains to be able to implement their own sub-field’s operators and write code in a way that looks more like their equations. This would open zig for maximum domain diversity without sacrificing readability or adding compiler complexity. I’m sure someone is going to ask for some modifications which would allow for pipe style chaining, but I’m kind of against it, I don’t think it fits in zig procedural identity.

The title gives the wrong impression, something like ‘Alternatives to operator overloading’ or ‘infix functions’. The title should describe what you are presenting, not the start of your train of thought.


I dont think zig will do this because you’re just adding another syntax to call functions if they meet some criteria.

zig tries to have 1 obvious way to do things, this is not so obvious, but it is another way to do functions.

1 Like

You’re right about the title, I fixed it.

zig tries to have 1 obvious way to do things, this is not so obvious, but it is another way to do functions.

I’ve obviously (pun intended) been thinking about this for a while, while you’ve just been presented the idea and are still trying to grasp with it, so give it a while.

I can agree that its another way to call functions, but I have 2 counter arguments, which are based on the fact that zig is still pre-1.0. The first is that there are still many ergonomic pain points at the syntax level. Just look at all the confusion around which polymorphism strategy to choose and allocator/io-writer vtables, or lack of non-integer backed packed structs and do-while. If those don’t have obvious solutions, mine is just another one on the pile. The second is that it “fits” inside the same category of problems that comptime solve, the closest analog being comptime for, which is just a macro for loop unrolling. If we can have language semantics for loop unrolling, why can’t we have them for infix functions? Additionally to that, they added switch continue statements last release to turn switch statements into loops, which I’m sure was not a problem most programmers needed.

I dont think zig will do this because you’re just adding another syntax to call functions if they meet some criteria.

You’re mis-reading my post between the lines as a request for this to be added to the language, which it really isn’t. What I’d really like is to see what ideas, thoughts and potential solutions others have in regards to people hijacking operator overloading when what they actually want most of the time is custom infix functions. Whatever solution or not that arrives with 1.0 is what we’ll have to accept and live with at the end of the day, but that shouldn’t stop people from proposing syntax improvements before, and even after.

3 Likes

If you’re looking for alternatives to operator overloading, there’s comath out there. I’m in the middle of integrating it for my own module.

1 Like

Yes it was! I didn’t know before, but it was needed! I love it!

Hey, I had comath amongst others exactly in mind in my first paragraph. The fact that you need to type your arguments twice and be aware of the stringification is the weakness of that one. The stringification means that you can’t use symbol rename in your editor without things breaking. But it is top notch infix solution that’s available with what we have now.

I must also admit, my guilty pleasure is abusing switch continue statements with defer, which looks absolutely convoluted, but is such a great control flow combination. But I didn’t have problems structuring code before and would have been fine if it never came along.

The primary ergonomic concern in zig atm is casting, the normal function call syntax is so common across languages that everyone is used to it, throwing an alternative is imo going add ergonomic issues.

The lack of ergonomics by not having an interface language feature is intentional, interfaces are costly both for performance and dev complexity. This encourages you to avoid them unless necessary, it also doesn’t single out one as a default, you can implement them however works best for your use case.

if you mean byte packed, opposed to zigs bit packed, zig does support this struct { a: X align(1), b: Y align(1) }, I agree it isnt obvious, unless you are familiar with memory layout.

This one I agree with.

Unrolling is useful for performance and allows the loop capture to be comptime known. Your suggestion is just syntax convenience.

labeled switch loops more consistantly generate the most optimal machine code than the alternative switch in a loop. It also makes state machines much more ergonomic to write by hand, both of which benefit compiler development and other areas. I use it often, IMO it’s one of zig’s nicest features.

Youre right, most often brainstorming + language means a suggestion for zig. perhaps the language-dev tag would make this more clear, also just explain that at the top of your post to avoid confusion.

I am not opposed to that discussion, I think your points are well-thought-out, just not for zig + the above hence my response.

1 Like

IMHO: shading languages don’t have an operator for cross and dot product either and it works just fine (HLSL doesn’t even have a matrix-multiply operator which is not fine ;), and the shading language feature set (vector and matrix math up to 4 dimensions) is all that’s needed to cover about 95% of use cases - also IMHO of course.

1 Like

I don’t know if this is wise to say, but I think the idea is that if the lack of operator overloading is an actual pain point of Zig for you, you’re not thinking along the lines that would lead you to write your best Zig code anyway.

While the casting problem does exist, my solution to this one was creating comptime functions in an utils.zig file that wraps the most common casting combinations the project needs. Most of the time, it’s casting to and from usize to other integers/enums.

Not that it’s unbelievable, but I’d like some reference where the lack of an interface mechanism as an intentional goal is stated. Could be any core team member tweet or github comment, but my understanding was that they hadn’t figured out or settled on something, not that they were against one.

That’s not the packed struct probem I’m referring to, align works as expected, the problem is that you can’t create fields that are arrays(or @Vectors) inside packed structs. And if you don’t have packed structs, then you can’t have guaranteed memory layout, a common problem is decoding network packets or headers for various protocols.

I’m very aware of what the performance implications of loop unrolling and jump tables that comptime for and switch continue add, but you must admit that it “adds” to the language, contradicting the “simplicity” of it. You are perfectly capable of unrolling your loop manually with explicit types, which would have generated the same asm, so I don’t agree that it’s anymore than a syntax convenience. Any codegen/macro is “just” a syntax convenience, but we all love comptime for a reason. At the end of the day pragmatism is best, it depends where the language wants to position itself segment wise. Those additions are justified for the cpu-centric nature of zig, I’d just sad if gamedev and gpus would be excluded.

Your doubts are probably that it’s unwise to infer someones code quality based on them expressing a pain point in the language, at which point, I would have asked you to show me what “your best Zig code” looks like.

If you read my post properly, I’m not asking for operator overloading, and what is considered “best Zig code” is subjective and changing on every release, with with new language features or stdlib reorgs.

1 Like

I suppose graphics shading languages can get away without matrix operators, from an outsider’s pov it seems to lean more trigonometry heavy which doesn’t need infix calling. I was thinking more compute shaders and gpgpu programming, and things that people use numpy for.

Anyway you can have all operators you want already, just implement them as functions:

struct Matrix {
    fn add(lhs: Matrix, rhs: Matrix) Matrix {}
}
m3 = m1.add(m2);
1 Like

arrays are not bit packed so ofc they cant be in a zig packed struct, and @Vector representation is left up to the backend, so zig cant put them anywhere that needs defined memory layout.

you could make a function to generate a packed tuple in place of either of those.

yes, zig does not sacrifice expressiveness for simplicity. The simplicity of zig is that it avoids adding things unless you can convince the core team and Andrew that it is worth it. Which is a very hard thing to do. Zig compared to other modern low level languages is quite simple conceptually.

yes, but that would mean not all zig can be run in comptime, it needs to be able to unroll loops to run arbitrary functions at comptime, the only limitation comptime has is it can’t interact with the environment.

You keep bringing this up, but zig fully intends to be usable for gpgpu. CPUs are just the easier, and better understood, target. zig doesn’t exclude them, it’s just a lower priority atm, when it does become a larger focus I would assume they would do more language changes to meet that end.

But I still don’t expect any operator overloading nor infix functions. Rather of some @Matrix type, there is also talk of remove @Vector and just do maths with arrays directly, which I think would extend to matrices through multidimensional arrays.

I am quite certain I have heard andrew/team member talking about the friction with interfaces being good, especially considering how much they talk about the application of friction with language features.
But there is a lot interface talk to slog through by people new to zig that andrew/team have been avoiding them, so its hard to find a even a single comment from them among the mess.
this is the closest I have found

I’m not saying there won’t be interfaces of any kind, ever, but there are no current plans for adding them, and this proposal is incomplete. It does not propose anything specific.

I suggest if you want to use zig, you accept the fact that it may never gain an interface-like feature, and also trust the core team to add it in a satisfactory manner if it does get added at all.

I’m just going to ask them, I’ll be back when I get a response

If we’re getting rid of @Vector no interface mechanics, that’s fine by me. I think it would be unfortunate if anyopaque ends up being the canonical solution, as it is used as the workaround in so many places in the stdlib and the complaints about it justified.

I think either one of us is confused about my comptime comment. You correct in restating that you can run comptime functions inside non comptime functions, but not the inverse. But comptime for is not a prerequesite for loop unrolling in comptime functions. You can unroll your loops manually inside comptime functions and non-comptime functions. I’d be happy to be proven wrong if that’s not the case.

I know that it intends to be usable for the gpu, (spir-v and whatnot), but unless there’s any indication that there are going to be language changes to it, me and others will just assume that it’s as a platform target with the current language feature set. It feels so far away, like 5+ years away, especially with io interface, async/await, and new compiler all having higher priority.

Thanks for asking, I wouldn’t dare to bother them myself.

At any rate, thanks for also engaging with me in (what I believe is) mostly good faith and high effort. But the tangent side debate we’re having on what merits to be language change/feature is not what I had in mind when starting this thread.

2 Likes

In case you’re not familiar with the issue, the problem is that you end up the equivalent of this:

f = sum(sum(sum(sum(a, b), c), d), e);

when you need to chain multiple operations together.

You can:

const Integer = struct {
    n: i64,

    fn add(lhs: Self, rhs: Self) Self {
        return .{ .n = lhs.n + rhs.n };
    }

    const Self = @This();
};

pub fn main() void {
    const a = Integer{ .n = 3 };
    const b = Integer{ .n = 5 };
    const c = Integer{ .n = 8 };
    const n = a.add(b).add(c);
    std.debug.print("{}\n", .{n.n}); // 16
}
4 Likes

I don’t follow, and I don’t agree that not allowing operator overloading means zig isn’t taking mathematical domains seriously.

Matrix types are an accepted proposal, where do you see it being shot down for the reasons you give?

My question is, what benefit is there? programmer comfort? because it does increase the complexity of the language, by adding a second means of calling specific functions.

I once again cannot follow this. Your proposals doesn’t have anything to do with those examples. so it’s not just another one on the pile. You are taking a thing (calling a function) that has one obvious way to do it and adding a non-obvious way to do it.

What did you have in mind?

What about something like this? (Implemented for a type that doesn’t need it but you can extrapolate to other types) :

const Op = enum { @"+", @"*" };

const X = struct {
    val: f32,

    fn @"_"(lhs: X, comptime op: Op, rhs: X) X {
        return .{
            .val = switch (op) {
                .@"+" => lhs.val + rhs.val,
                .@"*" => lhs.val * rhs.val,
            }
        };
    }
};

pub fn main() void {
    const x: X = .{ .val = 2.0 };
    const y: X = .{ .val = 3.0 };
    const z: X = ._( x, .@"+", y);
    const w: X = ._( x, .@"*", y);

    std.debug.print("{}\n", .{ z });
    std.debug.print("{}\n", .{ w });
}

If you squint/train your brain to ignore the characters . _ ( , @ " ) then you have operator overloading and infix…

8 Likes

There seems to be a lot of things you don’t follow in what I said, can you expand more which points or ideas in my post(s) have discontinuties? I’d be happy clarify.

You’re right, “not taking math domains seriously” is too hyperbolic and unfair. I should have phrased it as makes it “un-ergonomic to work with math”. Even matlab doesn’t have infix operators for complex types, but c++ and rust alt least have an escape hatch / hack in the form of operator overloading.

You are right about this too, but to be fair, this was as of 3 weeks ago, which I was unaware of until I searched just now: Builtin Matrix type · Issue #4960 · ziglang/zig · GitHub
This is an improvement, but the affordances won’t extend to other types. Are they also going to add an @Complex and @Tensor? That’s why I stated that it’s a more like a band-aid, rather than a holistic solution.

Yes, programmer comfort when implementing math heavy algorithms. I’m not ashamed to say it, it’s one my biggest needless trip-ups when I need to do this sort of work, with forward kinematics being my latest personal one. See the github issue 8204 later in this post.

I hope you can see how “Element X has attribute A, which none of the elements in set S {} have, therefore X is not a valid candidate of set S” is terribly faulty logic.

Probably revisiting this issue: Proposal: Ziglang Infix Function Sugar · Issue #8204 · ziglang/zig · GitHub
but under a different light, mostly by making it more generic, no base operators, only “userland” implemented functions with types, which solves a lot of the issues compiler cited as reason for closing the proposal.

Also, not making it a language proposal, I only wanted to hear what other people have found as a solution if they did or the real blockers of the idea that are not “this is just another way to call functions”. I think that the idea that language proposals are one man, one-shotted github/codeberg issues and judged as such to a final verdict doesn’t help converge efficiently to good solution. Discussion with others to see their side of the problem before making a proposal leads to less uncovered blind spots and less core dev time wasted evaluating proposals. However, “this is a feature I don’t/won’t use and would bloat the language” is not the type “blind spot” that I or proposal writers are looking for. Also if people have ever seen/used languages where user-defined infix functions ever got implemented. The switch continue feature for example, is equivalent to the old c duff devices of older times in functionality, but probably not in implementation. But it is a unique feature that did get implemented, both for performance and ergonomic reasons, adding complexity to the language as a cost, when a while true loop with a switch inside could have worked fine.

Also, if you have read the refusal post, the person did state that they closed it with difficulties, as they do see the readability value of infix operators, meaning that the additional language complexity might be justified if the alternative is to suffer the code complexity of:

sigma.mul(sqrt(2 * pi)).recip().mul(x.sub(mu).div(sigma).square().div(2).neg().exp());
1 Like